use crate::bitbucket::BitbucketIntegration;
use crate::cli::output::Output;
use crate::errors::{CascadeError, Result};
use crate::git::{find_repository_root, GitRepository};
use crate::stack::{CleanupManager, CleanupOptions, CleanupResult, StackManager, StackStatus};
use clap::{Subcommand, ValueEnum};
use dialoguer::{theme::ColorfulTheme, Confirm};
use std::env;
use tracing::{debug, warn};
use uuid::Uuid;
#[derive(ValueEnum, Clone, Debug)]
pub enum RebaseStrategyArg {
ForcePush,
Interactive,
}
#[derive(ValueEnum, Clone, Debug)]
pub enum MergeStrategyArg {
Merge,
Squash,
FastForward,
}
impl From<MergeStrategyArg> for crate::bitbucket::pull_request::MergeStrategy {
fn from(arg: MergeStrategyArg) -> Self {
match arg {
MergeStrategyArg::Merge => Self::Merge,
MergeStrategyArg::Squash => Self::Squash,
MergeStrategyArg::FastForward => Self::FastForward,
}
}
}
#[derive(Debug, Subcommand)]
pub enum StackAction {
Create {
name: String,
#[arg(long, short)]
base: Option<String>,
#[arg(long, short)]
description: Option<String>,
},
List {
#[arg(long, short)]
verbose: bool,
#[arg(long)]
active: bool,
#[arg(long)]
format: Option<String>,
},
Switch {
name: String,
},
Deactivate {
#[arg(long)]
force: bool,
},
Show {
#[arg(short, long)]
verbose: bool,
#[arg(short, long)]
mergeable: bool,
},
Push {
#[arg(long, short)]
branch: Option<String>,
#[arg(long, short)]
message: Option<String>,
#[arg(long)]
commit: Option<String>,
#[arg(long)]
since: Option<String>,
#[arg(long)]
commits: Option<String>,
#[arg(long, num_args = 0..=1, default_missing_value = "0")]
squash: Option<usize>,
#[arg(long)]
squash_since: Option<String>,
#[arg(long)]
auto_branch: bool,
#[arg(long)]
allow_base_branch: bool,
#[arg(long)]
dry_run: bool,
#[arg(long, short)]
yes: bool,
},
Pop {
#[arg(long)]
keep_branch: bool,
},
Submit {
entry: Option<usize>,
#[arg(long, short)]
title: Option<String>,
#[arg(long, short)]
description: Option<String>,
#[arg(long)]
range: Option<String>,
#[arg(long, default_value_t = true)]
draft: bool,
#[arg(long, default_value_t = true)]
open: bool,
},
Status {
name: Option<String>,
},
Prs {
#[arg(long)]
state: Option<String>,
#[arg(long, short)]
verbose: bool,
},
Check {
#[arg(long)]
force: bool,
},
Sync {
#[arg(long)]
force: bool,
#[arg(long)]
cleanup: bool,
#[arg(long, short)]
interactive: bool,
},
Rebase {
#[arg(long, short)]
interactive: bool,
#[arg(long)]
onto: Option<String>,
#[arg(long, value_enum)]
strategy: Option<RebaseStrategyArg>,
},
ContinueRebase,
AbortRebase,
RebaseStatus,
Delete {
name: String,
#[arg(long)]
force: bool,
},
Validate {
name: Option<String>,
#[arg(long)]
fix: Option<String>,
},
Land {
entry: Option<usize>,
#[arg(short, long)]
force: bool,
#[arg(short, long)]
dry_run: bool,
#[arg(long)]
auto: bool,
#[arg(long)]
wait_for_builds: bool,
#[arg(long, value_enum, default_value = "squash")]
strategy: Option<MergeStrategyArg>,
#[arg(long, default_value = "1800")]
build_timeout: u64,
},
AutoLand {
#[arg(short, long)]
force: bool,
#[arg(short, long)]
dry_run: bool,
#[arg(long)]
wait_for_builds: bool,
#[arg(long, value_enum, default_value = "squash")]
strategy: Option<MergeStrategyArg>,
#[arg(long, default_value = "1800")]
build_timeout: u64,
},
ListPrs {
#[arg(short, long)]
state: Option<String>,
#[arg(short, long)]
verbose: bool,
},
ContinueLand,
AbortLand,
LandStatus,
Cleanup {
#[arg(long)]
dry_run: bool,
#[arg(long)]
force: bool,
#[arg(long)]
include_stale: bool,
#[arg(long, default_value = "30")]
stale_days: u32,
#[arg(long)]
cleanup_remote: bool,
#[arg(long)]
include_non_stack: bool,
#[arg(long)]
verbose: bool,
},
Repair,
Drop {
entry: String,
#[arg(long)]
keep_branch: bool,
#[arg(long)]
keep_pr: bool,
#[arg(long, short)]
force: bool,
#[arg(long, short)]
yes: bool,
},
}
pub async fn run(action: StackAction) -> Result<()> {
match action {
StackAction::Create {
name,
base,
description,
} => create_stack(name, base, description).await,
StackAction::List {
verbose,
active,
format,
} => list_stacks(verbose, active, format).await,
StackAction::Switch { name } => switch_stack(name).await,
StackAction::Deactivate { force } => deactivate_stack(force).await,
StackAction::Show { verbose, mergeable } => show_stack(verbose, mergeable).await,
StackAction::Push {
branch,
message,
commit,
since,
commits,
squash,
squash_since,
auto_branch,
allow_base_branch,
dry_run,
yes,
} => {
push_to_stack(
branch,
message,
commit,
since,
commits,
squash,
squash_since,
auto_branch,
allow_base_branch,
dry_run,
yes,
)
.await
}
StackAction::Pop { keep_branch } => pop_from_stack(keep_branch).await,
StackAction::Submit {
entry,
title,
description,
range,
draft,
open,
} => submit_entry(entry, title, description, range, draft, open).await,
StackAction::Status { name } => check_stack_status(name).await,
StackAction::Prs { state, verbose } => list_pull_requests(state, verbose).await,
StackAction::Check { force } => check_stack(force).await,
StackAction::Sync {
force,
cleanup,
interactive,
} => sync_stack(force, cleanup, interactive).await,
StackAction::Rebase {
interactive,
onto,
strategy,
} => rebase_stack(interactive, onto, strategy).await,
StackAction::ContinueRebase => continue_rebase().await,
StackAction::AbortRebase => abort_rebase().await,
StackAction::RebaseStatus => rebase_status().await,
StackAction::Delete { name, force } => delete_stack(name, force).await,
StackAction::Validate { name, fix } => validate_stack(name, fix).await,
StackAction::Land {
entry,
force,
dry_run,
auto,
wait_for_builds,
strategy,
build_timeout,
} => {
land_stack(
entry,
force,
dry_run,
auto,
wait_for_builds,
strategy,
build_timeout,
)
.await
}
StackAction::AutoLand {
force,
dry_run,
wait_for_builds,
strategy,
build_timeout,
} => auto_land_stack(force, dry_run, wait_for_builds, strategy, build_timeout).await,
StackAction::ListPrs { state, verbose } => list_pull_requests(state, verbose).await,
StackAction::ContinueLand => continue_land().await,
StackAction::AbortLand => abort_land().await,
StackAction::LandStatus => land_status().await,
StackAction::Cleanup {
dry_run,
force,
include_stale,
stale_days,
cleanup_remote,
include_non_stack,
verbose,
} => {
cleanup_branches(
dry_run,
force,
include_stale,
stale_days,
cleanup_remote,
include_non_stack,
verbose,
)
.await
}
StackAction::Repair => repair_stack_data().await,
StackAction::Drop {
entry,
keep_branch,
keep_pr,
force,
yes,
} => drop_entries(entry, keep_branch, keep_pr, force, yes).await,
}
}
pub async fn show(verbose: bool, mergeable: bool) -> Result<()> {
show_stack(verbose, mergeable).await
}
#[allow(clippy::too_many_arguments)]
pub async fn push(
branch: Option<String>,
message: Option<String>,
commit: Option<String>,
since: Option<String>,
commits: Option<String>,
squash: Option<usize>,
squash_since: Option<String>,
auto_branch: bool,
allow_base_branch: bool,
dry_run: bool,
yes: bool,
) -> Result<()> {
push_to_stack(
branch,
message,
commit,
since,
commits,
squash,
squash_since,
auto_branch,
allow_base_branch,
dry_run,
yes,
)
.await
}
pub async fn pop(keep_branch: bool) -> Result<()> {
pop_from_stack(keep_branch).await
}
pub async fn drop(
entry: String,
keep_branch: bool,
keep_pr: bool,
force: bool,
yes: bool,
) -> Result<()> {
drop_entries(entry, keep_branch, keep_pr, force, yes).await
}
pub async fn land(
entry: Option<usize>,
force: bool,
dry_run: bool,
auto: bool,
wait_for_builds: bool,
strategy: Option<MergeStrategyArg>,
build_timeout: u64,
) -> Result<()> {
land_stack(
entry,
force,
dry_run,
auto,
wait_for_builds,
strategy,
build_timeout,
)
.await
}
pub async fn autoland(
force: bool,
dry_run: bool,
wait_for_builds: bool,
strategy: Option<MergeStrategyArg>,
build_timeout: u64,
) -> Result<()> {
auto_land_stack(force, dry_run, wait_for_builds, strategy, build_timeout).await
}
pub async fn sync(force: bool, skip_cleanup: bool, interactive: bool) -> Result<()> {
sync_stack(force, skip_cleanup, interactive).await
}
pub async fn rebase(
interactive: bool,
onto: Option<String>,
strategy: Option<RebaseStrategyArg>,
) -> Result<()> {
rebase_stack(interactive, onto, strategy).await
}
pub async fn deactivate(force: bool) -> Result<()> {
deactivate_stack(force).await
}
pub async fn switch(name: String) -> Result<()> {
switch_stack(name).await
}
async fn create_stack(
name: String,
base: Option<String>,
description: Option<String>,
) -> Result<()> {
let current_dir = env::current_dir()
.map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
let repo_root = find_repository_root(¤t_dir)
.map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
let mut manager = StackManager::new(&repo_root)?;
let stack_id = manager.create_stack(name.clone(), base.clone(), description.clone())?;
let stack = manager
.get_stack(&stack_id)
.ok_or_else(|| CascadeError::config("Failed to get created stack"))?;
Output::stack_info(
&name,
&stack_id.to_string(),
&stack.base_branch,
stack.working_branch.as_deref(),
true, );
if let Some(desc) = description {
Output::sub_item(format!("Description: {desc}"));
}
if stack.working_branch.is_none() {
Output::warning(format!(
"You're currently on the base branch '{}'",
stack.base_branch
));
Output::next_steps(&[
&format!("Create a feature branch: git checkout -b {name}"),
"Make changes and commit them",
"Run 'ca push' to add commits to this stack",
]);
} else {
Output::next_steps(&[
"Make changes and commit them",
"Run 'ca push' to add commits to this stack",
"Use 'ca submit' when ready to create pull requests",
]);
}
Ok(())
}
async fn list_stacks(verbose: bool, active_only: bool, format: Option<String>) -> Result<()> {
let current_dir = env::current_dir()
.map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
let repo_root = find_repository_root(¤t_dir)
.map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
let manager = StackManager::new(&repo_root)?;
let mut stacks = manager.list_stacks();
if active_only {
stacks.retain(|(_, _, _, _, active_marker)| active_marker.is_some());
}
if let Some(ref format) = format {
match format.as_str() {
"json" => {
let mut json_stacks = Vec::new();
for (stack_id, name, status, entry_count, active_marker) in &stacks {
let (entries_json, working_branch, base_branch) =
if let Some(stack_obj) = manager.get_stack(stack_id) {
let entries_json = stack_obj
.entries
.iter()
.enumerate()
.map(|(idx, entry)| {
serde_json::json!({
"position": idx + 1,
"entry_id": entry.id.to_string(),
"branch_name": entry.branch.clone(),
"commit_hash": entry.commit_hash.clone(),
"short_hash": entry.short_hash(),
"is_submitted": entry.is_submitted,
"is_merged": entry.is_merged,
"pull_request_id": entry.pull_request_id.clone(),
})
})
.collect::<Vec<_>>();
(
entries_json,
stack_obj.working_branch.clone(),
Some(stack_obj.base_branch.clone()),
)
} else {
(Vec::new(), None, None)
};
let status_label = format!("{status:?}");
json_stacks.push(serde_json::json!({
"id": stack_id.to_string(),
"name": name,
"status": status_label,
"entry_count": entry_count,
"is_active": active_marker.is_some(),
"base_branch": base_branch,
"working_branch": working_branch,
"entries": entries_json,
}));
}
let json_output = serde_json::json!({ "stacks": json_stacks });
let serialized = serde_json::to_string_pretty(&json_output)?;
println!("{serialized}");
return Ok(());
}
"name" => {
for (_, name, _, _, _) in &stacks {
println!("{name}");
}
return Ok(());
}
"id" => {
for (stack_id, _, _, _, _) in &stacks {
println!("{}", stack_id);
}
return Ok(());
}
"status" => {
for (_, name, status, _, active_marker) in &stacks {
let status_label = format!("{status:?}");
let marker = if active_marker.is_some() {
" (active)"
} else {
""
};
println!("{name}: {status_label}{marker}");
}
return Ok(());
}
other => {
return Err(CascadeError::config(format!(
"Unsupported format '{}'. Supported formats: name, id, status, json",
other
)));
}
}
}
if stacks.is_empty() {
if active_only {
Output::info("No active stack. Activate one with 'ca stack switch <name>'");
} else {
Output::info("No stacks found. Create one with: ca stack create <name>");
}
return Ok(());
}
println!("Stacks:");
for (stack_id, name, status, entry_count, active_marker) in stacks {
let status_icon = match status {
StackStatus::Clean => "✓",
StackStatus::Dirty => "~",
StackStatus::OutOfSync => "!",
StackStatus::Conflicted => "✗",
StackStatus::Rebasing => "↔",
StackStatus::NeedsSync => "~",
StackStatus::Corrupted => "✗",
};
let active_indicator = if active_marker.is_some() {
" (active)"
} else {
""
};
let stack = manager.get_stack(&stack_id);
if verbose {
println!(" {status_icon} {name} [{entry_count}]{active_indicator}");
println!(" ID: {stack_id}");
if let Some(stack_meta) = manager.get_stack_metadata(&stack_id) {
println!(" Base: {}", stack_meta.base_branch);
if let Some(desc) = &stack_meta.description {
println!(" Description: {desc}");
}
println!(
" Commits: {} total, {} submitted",
stack_meta.total_commits, stack_meta.submitted_commits
);
if stack_meta.has_conflicts {
Output::warning(" Has conflicts");
}
}
if let Some(stack_obj) = stack {
if !stack_obj.entries.is_empty() {
println!(" Branches:");
for (i, entry) in stack_obj.entries.iter().enumerate() {
let entry_num = i + 1;
let submitted_indicator = if entry.is_submitted {
"[submitted]"
} else {
""
};
let branch_name = &entry.branch;
let short_message = if entry.message.len() > 40 {
format!("{}...", &entry.message[..37])
} else {
entry.message.clone()
};
println!(" {entry_num}. {submitted_indicator} {branch_name} - {short_message}");
}
}
}
println!();
} else {
let branch_info = if let Some(stack_obj) = stack {
if stack_obj.entries.is_empty() {
String::new()
} else if stack_obj.entries.len() == 1 {
format!(" → {}", stack_obj.entries[0].branch)
} else {
let first_branch = &stack_obj.entries[0].branch;
let last_branch = &stack_obj.entries.last().unwrap().branch;
format!(" → {first_branch} … {last_branch}")
}
} else {
String::new()
};
println!(" {status_icon} {name} [{entry_count}]{branch_info}{active_indicator}");
}
}
if !verbose {
println!("\nUse --verbose for more details");
}
Ok(())
}
async fn switch_stack(name: String) -> Result<()> {
let current_dir = env::current_dir()
.map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
let repo_root = find_repository_root(¤t_dir)
.map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
let manager = StackManager::new(&repo_root)?;
let repo = GitRepository::open(&repo_root)?;
let stack = manager
.get_stack_by_name(&name)
.ok_or_else(|| CascadeError::config(format!("Stack '{name}' not found")))?;
if let Some(working_branch) = &stack.working_branch {
let current_branch = repo.get_current_branch().ok();
if current_branch.as_ref() != Some(working_branch) {
Output::progress(format!(
"Switching to stack working branch: {working_branch}"
));
if repo.branch_exists(working_branch) {
match repo.checkout_branch(working_branch) {
Ok(_) => {
Output::success(format!("Checked out branch: {working_branch}"));
}
Err(e) => {
Output::warning(format!("Failed to checkout '{working_branch}': {e}"));
Output::sub_item("Stack activated but stayed on current branch");
Output::sub_item(format!(
"You can manually checkout with: git checkout {working_branch}"
));
}
}
} else {
Output::warning(format!(
"Stack working branch '{working_branch}' doesn't exist locally"
));
Output::sub_item("Stack activated but stayed on current branch");
Output::sub_item(format!(
"You may need to fetch from remote: git fetch origin {working_branch}"
));
}
} else {
Output::success(format!("Already on stack working branch: {working_branch}"));
}
} else {
Output::warning(format!("Stack '{name}' has no working branch set"));
Output::sub_item(
"This typically happens when a stack was created while on the base branch",
);
Output::tip("To start working on this stack:");
Output::bullet(format!("Create a feature branch: git checkout -b {name}"));
Output::bullet("The stack will automatically track this as its working branch");
Output::bullet("Then use 'ca push' to add commits to the stack");
Output::sub_item(format!("Base branch: {}", stack.base_branch));
}
Output::success(format!("Switched to stack '{name}'"));
Ok(())
}
async fn deactivate_stack(_force: bool) -> Result<()> {
Output::warning("'ca deactivate' is no longer needed.");
Output::sub_item("Active stack is now resolved from your current branch.");
Output::sub_item("To leave a stack, just switch to your base branch: git checkout main");
Ok(())
}
async fn show_stack(verbose: bool, show_mergeable: bool) -> Result<()> {
let current_dir = env::current_dir()
.map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
let repo_root = find_repository_root(¤t_dir)
.map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
let stack_manager = StackManager::new(&repo_root)?;
let (stack_id, stack_name, stack_base, stack_working, stack_entries) = {
let active_stack = stack_manager.get_active_stack().ok_or_else(|| {
CascadeError::config(
"No active stack. Use 'ca stacks create' or 'ca stacks switch' to select a stack"
.to_string(),
)
})?;
(
active_stack.id,
active_stack.name.clone(),
active_stack.base_branch.clone(),
active_stack.working_branch.clone(),
active_stack.entries.clone(),
)
};
Output::stack_info(
&stack_name,
&stack_id.to_string(),
&stack_base,
stack_working.as_deref(),
true, );
Output::sub_item(format!("Total entries: {}", stack_entries.len()));
if stack_entries.is_empty() {
Output::info("No entries in this stack yet");
Output::tip("Use 'ca push' to add commits to this stack");
return Ok(());
}
let refreshed_entries = if let Ok(config_dir) = crate::config::get_repo_config_dir(&repo_root) {
let config_path = config_dir.join("config.json");
if let Ok(settings) = crate::config::Settings::load_from_file(&config_path) {
let cascade_config = crate::config::CascadeConfig {
bitbucket: Some(settings.bitbucket.clone()),
git: settings.git.clone(),
auth: crate::config::AuthConfig::default(),
cascade: settings.cascade.clone(),
};
if let Ok(integration_stack_manager) = StackManager::new(&repo_root) {
let mut integration = crate::bitbucket::BitbucketIntegration::new(
integration_stack_manager,
cascade_config,
)
.ok();
if let Some(ref mut integ) = integration {
let spinner =
crate::utils::spinner::Spinner::new("Checking PR status...".to_string());
let _ = integ.check_enhanced_stack_status(&stack_id).await;
spinner.stop();
if let Ok(updated_manager) = StackManager::new(&repo_root) {
if let Some(updated_stack) = updated_manager.get_stack(&stack_id) {
updated_stack.entries.clone()
} else {
stack_entries.clone()
}
} else {
stack_entries.clone()
}
} else {
stack_entries.clone()
}
} else {
stack_entries.clone()
}
} else {
stack_entries.clone()
}
} else {
stack_entries.clone()
};
Output::section("Stack Entries");
for (i, entry) in refreshed_entries.iter().enumerate() {
let entry_num = i + 1;
let short_hash = entry.short_hash();
let short_msg = entry.short_message(50);
let stack_manager_for_metadata = StackManager::new(&repo_root)?;
let metadata = stack_manager_for_metadata.get_repository_metadata();
let source_branch_info = if !entry.is_submitted {
if let Some(commit_meta) = metadata.get_commit(&entry.commit_hash) {
if commit_meta.source_branch != commit_meta.branch
&& !commit_meta.source_branch.is_empty()
{
format!(" (from {})", commit_meta.source_branch)
} else {
String::new()
}
} else {
String::new()
}
} else {
String::new()
};
let status_colored = Output::entry_status(entry.is_submitted, entry.is_merged);
Output::numbered_item(
entry_num,
format!("{short_hash} {status_colored} {short_msg}{source_branch_info}"),
);
if verbose {
Output::sub_item(format!("Branch: {}", entry.branch));
Output::sub_item(format!(
"Created: {}",
entry.created_at.format("%Y-%m-%d %H:%M")
));
if let Some(pr_id) = &entry.pull_request_id {
Output::sub_item(format!("PR: #{pr_id}"));
}
Output::sub_item("Commit Message:");
let lines: Vec<&str> = entry.message.lines().collect();
for line in lines {
Output::sub_item(format!(" {line}"));
}
}
}
if show_mergeable {
Output::section("Mergeability Status");
let config_dir = crate::config::get_repo_config_dir(&repo_root)?;
let config_path = config_dir.join("config.json");
let settings = crate::config::Settings::load_from_file(&config_path)?;
let cascade_config = crate::config::CascadeConfig {
bitbucket: Some(settings.bitbucket.clone()),
git: settings.git.clone(),
auth: crate::config::AuthConfig::default(),
cascade: settings.cascade.clone(),
};
let mut integration =
crate::bitbucket::BitbucketIntegration::new(stack_manager, cascade_config)?;
let spinner =
crate::utils::spinner::Spinner::new("Fetching detailed PR status...".to_string());
let status_result = integration.check_enhanced_stack_status(&stack_id).await;
spinner.stop();
match status_result {
Ok(mut status) => {
let advisory_patterns = &settings.cascade.advisory_merge_checks;
if !advisory_patterns.is_empty() {
for enhanced in &mut status.enhanced_statuses {
enhanced.apply_advisory_filters(advisory_patterns);
}
}
Output::bullet(format!("Total entries: {}", status.total_entries));
Output::bullet(format!("Submitted: {}", status.submitted_entries));
Output::bullet(format!("Open PRs: {}", status.open_prs));
Output::bullet(format!("Merged PRs: {}", status.merged_prs));
Output::bullet(format!("Declined PRs: {}", status.declined_prs));
Output::bullet(format!(
"Completion: {:.1}%",
status.completion_percentage()
));
if !status.enhanced_statuses.is_empty() {
Output::section("Pull Request Status");
let mut ready_to_land = 0;
for enhanced in &status.enhanced_statuses {
use console::style;
let (ready_badge, show_details) = match enhanced.pr.state {
crate::bitbucket::pull_request::PullRequestState::Merged => {
(style("[MERGED]").green().bold().to_string(), false)
}
crate::bitbucket::pull_request::PullRequestState::Declined => {
(style("[DECLINED]").red().bold().to_string(), false)
}
crate::bitbucket::pull_request::PullRequestState::Open => {
if enhanced.is_ready_to_land() {
ready_to_land += 1;
(style("[READY]").cyan().bold().to_string(), true)
} else {
(style("[PENDING]").yellow().bold().to_string(), true)
}
}
};
Output::bullet(format!(
"{} PR #{}: {}",
ready_badge, enhanced.pr.id, enhanced.pr.title
));
if show_details {
let build_display = if let Some(build) = &enhanced.build_status {
match build.state {
crate::bitbucket::pull_request::BuildState::Successful => {
style("Passing").green().to_string()
}
crate::bitbucket::pull_request::BuildState::Failed => {
style("Failing").red().to_string()
}
crate::bitbucket::pull_request::BuildState::InProgress => {
style("Running").yellow().to_string()
}
crate::bitbucket::pull_request::BuildState::Cancelled => {
style("Cancelled").dim().to_string()
}
crate::bitbucket::pull_request::BuildState::Unknown => {
style("Unknown").dim().to_string()
}
}
} else {
let blocking = enhanced.get_blocking_reasons();
if blocking.iter().any(|r| {
r.contains("required builds") || r.contains("Build Status")
}) {
style("Pending").yellow().to_string()
} else if blocking.is_empty() && enhanced.mergeable.unwrap_or(false)
{
style("Passing").green().to_string()
} else {
style("Unknown").dim().to_string()
}
};
println!(" Builds: {}", build_display);
let review_display = if enhanced.review_status.can_merge {
style("Approved").green().to_string()
} else if enhanced.review_status.needs_work_count > 0 {
style("Changes Requested").red().to_string()
} else if enhanced.review_status.current_approvals > 0
&& enhanced.review_status.required_approvals > 0
{
style(format!(
"{}/{} approvals",
enhanced.review_status.current_approvals,
enhanced.review_status.required_approvals
))
.yellow()
.to_string()
} else {
style("Pending").yellow().to_string()
};
println!(" Reviews: {}", review_display);
if !enhanced.mergeable.unwrap_or(false) {
let blocking = enhanced.get_blocking_reasons();
if !blocking.is_empty() {
let first_reason = &blocking[0];
let simplified = if first_reason.contains("Code Owners") {
"Waiting for Code Owners approval"
} else if first_reason.contains("required builds")
|| first_reason.contains("Build Status")
{
"Waiting for required builds"
} else if first_reason.contains("approvals")
|| first_reason.contains("Requires approvals")
{
"Waiting for approvals"
} else if first_reason.contains("conflicts") {
"Has merge conflicts"
} else {
"Blocked by repository policy"
};
println!(" Merge: {}", style(simplified).red());
}
} else if enhanced.is_ready_to_land() {
println!(" Merge: {}", style("Ready").green());
}
}
if verbose {
println!(
" {} -> {}",
enhanced.pr.from_ref.display_id, enhanced.pr.to_ref.display_id
);
if !enhanced.is_ready_to_land() {
let blocking = enhanced.get_blocking_reasons();
if !blocking.is_empty() {
println!(" Blocking: {}", blocking.join(", "));
}
}
println!(
" Reviews: {} approval{}",
enhanced.review_status.current_approvals,
if enhanced.review_status.current_approvals == 1 {
""
} else {
"s"
}
);
if enhanced.review_status.needs_work_count > 0 {
println!(
" {} reviewers requested changes",
enhanced.review_status.needs_work_count
);
}
if let Some(build) = &enhanced.build_status {
let build_icon = match build.state {
crate::bitbucket::pull_request::BuildState::Successful => "✓",
crate::bitbucket::pull_request::BuildState::Failed => "✗",
crate::bitbucket::pull_request::BuildState::InProgress => "~",
_ => "○",
};
println!(" Build: {} {:?}", build_icon, build.state);
}
if let Some(url) = enhanced.pr.web_url() {
println!(" URL: {url}");
}
println!();
}
}
if ready_to_land > 0 {
println!(
"\n🎯 {} PR{} ready to land! Use 'ca land' to land them all.",
ready_to_land,
if ready_to_land == 1 { " is" } else { "s are" }
);
}
}
}
Err(e) => {
tracing::debug!("Failed to get enhanced stack status: {}", e);
Output::warning("Could not fetch mergability status");
Output::sub_item("Use 'ca stack show --verbose' for basic PR information");
}
}
} else {
let config_dir = crate::config::get_repo_config_dir(&repo_root)?;
let config_path = config_dir.join("config.json");
let settings = crate::config::Settings::load_from_file(&config_path)?;
let cascade_config = crate::config::CascadeConfig {
bitbucket: Some(settings.bitbucket.clone()),
git: settings.git.clone(),
auth: crate::config::AuthConfig::default(),
cascade: settings.cascade.clone(),
};
let integration =
crate::bitbucket::BitbucketIntegration::new(stack_manager, cascade_config)?;
match integration.check_stack_status(&stack_id).await {
Ok(status) => {
println!("\nPull Request Status:");
println!(" Total entries: {}", status.total_entries);
println!(" Submitted: {}", status.submitted_entries);
println!(" Open PRs: {}", status.open_prs);
println!(" Merged PRs: {}", status.merged_prs);
println!(" Declined PRs: {}", status.declined_prs);
println!(" Completion: {:.1}%", status.completion_percentage());
if !status.pull_requests.is_empty() {
println!("\nPull Requests:");
for pr in &status.pull_requests {
use console::style;
let state_icon = match pr.state {
crate::bitbucket::PullRequestState::Open => {
style("→").cyan().to_string()
}
crate::bitbucket::PullRequestState::Merged => {
style("✓").green().to_string()
}
crate::bitbucket::PullRequestState::Declined => {
style("✗").red().to_string()
}
};
println!(
" {} PR {}: {} ({} {} {})",
state_icon,
style(format!("#{}", pr.id)).dim(),
pr.title,
style(&pr.from_ref.display_id).dim(),
style("→").dim(),
style(&pr.to_ref.display_id).dim()
);
if let Some(url) = pr.web_url() {
println!(" URL: {}", style(url).cyan().underlined());
}
}
}
println!();
Output::tip("Use 'ca stack --mergeable' to see detailed status including build and review information");
}
Err(e) => {
tracing::debug!("Failed to check stack status: {}", e);
}
}
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn push_to_stack(
branch: Option<String>,
message: Option<String>,
commit: Option<String>,
since: Option<String>,
commits: Option<String>,
squash: Option<usize>,
squash_since: Option<String>,
auto_branch: bool,
allow_base_branch: bool,
dry_run: bool,
yes: bool,
) -> Result<()> {
let current_dir = env::current_dir()
.map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
let repo_root = find_repository_root(¤t_dir)
.map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
let mut manager = StackManager::new(&repo_root)?;
let repo = GitRepository::open(&repo_root)?;
let active_stack = manager.get_active_stack().ok_or_else(|| {
CascadeError::config("No active stack. Create a stack first with 'ca stack create'")
})?;
let current_branch = repo.get_current_branch()?;
let base_branch = &active_stack.base_branch;
if current_branch == *base_branch {
Output::error(format!(
"You're currently on the base branch '{base_branch}'"
));
Output::sub_item("Making commits directly on the base branch is not recommended.");
Output::sub_item("This can pollute the base branch with work-in-progress commits.");
if allow_base_branch {
Output::warning("Proceeding anyway due to --allow-base-branch flag");
} else {
let has_changes = repo.is_dirty()?;
if has_changes {
if auto_branch {
let feature_branch = format!("feature/{}-work", active_stack.name);
Output::progress(format!(
"Auto-creating feature branch '{feature_branch}'..."
));
repo.create_branch(&feature_branch, None)?;
repo.checkout_branch(&feature_branch)?;
Output::success(format!("Created and switched to '{feature_branch}'"));
println!(" You can now commit and push your changes safely");
} else {
println!("\nYou have uncommitted changes. Here are your options:");
println!(" 1. Create a feature branch first:");
println!(" git checkout -b feature/my-work");
println!(" git commit -am \"your work\"");
println!(" ca push");
println!("\n 2. Auto-create a branch (recommended):");
println!(" ca push --auto-branch");
println!("\n 3. Force push to base branch (dangerous):");
println!(" ca push --allow-base-branch");
return Err(CascadeError::config(
"Refusing to push uncommitted changes from base branch. Use one of the options above."
));
}
} else {
let commits_to_check = if let Some(commits_str) = &commits {
commits_str
.split(',')
.map(|s| s.trim().to_string())
.collect::<Vec<String>>()
} else if let Some(since_ref) = &since {
let since_commit = repo.resolve_reference(since_ref)?;
let head_commit = repo.get_head_commit()?;
let commits = repo.get_commits_between(
&since_commit.id().to_string(),
&head_commit.id().to_string(),
)?;
commits.into_iter().map(|c| c.id().to_string()).collect()
} else if commit.is_none() {
let mut unpushed = Vec::new();
let head_commit = repo.get_head_commit()?;
let mut current_commit = head_commit;
loop {
let commit_hash = current_commit.id().to_string();
let already_in_stack = active_stack
.entries
.iter()
.any(|entry| entry.commit_hash == commit_hash);
if already_in_stack {
break;
}
unpushed.push(commit_hash);
if let Some(parent) = current_commit.parents().next() {
current_commit = parent;
} else {
break;
}
}
unpushed.reverse();
unpushed
} else {
vec![repo.get_head_commit()?.id().to_string()]
};
if !commits_to_check.is_empty() {
if auto_branch {
let feature_branch = format!("feature/{}-work", active_stack.name);
Output::progress(format!(
"Auto-creating feature branch '{feature_branch}'..."
));
repo.create_branch(&feature_branch, Some(base_branch))?;
repo.checkout_branch(&feature_branch)?;
println!(
"🍒 Cherry-picking {} commit(s) to new branch...",
commits_to_check.len()
);
for commit_hash in &commits_to_check {
match repo.cherry_pick(commit_hash) {
Ok(_) => println!(" ✅ Cherry-picked {}", &commit_hash[..8]),
Err(e) => {
Output::error(format!(
"Failed to cherry-pick {}: {}",
&commit_hash[..8],
e
));
Output::tip("You may need to resolve conflicts manually");
return Err(CascadeError::branch(format!(
"Failed to cherry-pick commit {commit_hash}: {e}"
)));
}
}
}
println!(
"✅ Successfully moved {} commit(s) to '{feature_branch}'",
commits_to_check.len()
);
println!(
" You're now on the feature branch and can continue with 'ca push'"
);
} else {
println!(
"\n💡 Found {} commit(s) to push from base branch '{base_branch}'",
commits_to_check.len()
);
println!(" These commits are currently ON the base branch, which may not be intended.");
println!("\n Options:");
println!(" 1. Auto-create feature branch and cherry-pick commits:");
println!(" ca push --auto-branch");
println!("\n 2. Manually create branch and move commits:");
println!(" git checkout -b feature/my-work");
println!(" ca push");
println!("\n 3. Force push from base branch (not recommended):");
println!(" ca push --allow-base-branch");
return Err(CascadeError::config(
"Refusing to push commits from base branch. Use --auto-branch or create a feature branch manually."
));
}
}
}
}
}
if let Some(squash_count) = squash {
if squash_count == 0 {
let active_stack = manager.get_active_stack().ok_or_else(|| {
CascadeError::config(
"No active stack. Create a stack first with 'ca stacks create'",
)
})?;
let unpushed_count = get_unpushed_commits(&repo, active_stack)?.len();
if unpushed_count == 0 {
Output::info(" No unpushed commits to squash");
} else if unpushed_count == 1 {
Output::info(" Only 1 unpushed commit, no squashing needed");
} else {
println!(" Auto-detected {unpushed_count} unpushed commits, squashing...");
squash_commits(&repo, unpushed_count, None).await?;
Output::success(" Squashed {unpushed_count} unpushed commits into one");
}
} else {
println!(" Squashing last {squash_count} commits...");
squash_commits(&repo, squash_count, None).await?;
Output::success(" Squashed {squash_count} commits into one");
}
} else if let Some(since_ref) = squash_since {
println!(" Squashing commits since {since_ref}...");
let since_commit = repo.resolve_reference(&since_ref)?;
let commits_count = count_commits_since(&repo, &since_commit.id().to_string())?;
squash_commits(&repo, commits_count, Some(since_ref.clone())).await?;
Output::success(" Squashed {commits_count} commits since {since_ref} into one");
}
if commits.is_none() && since.is_none() && commit.is_none() {
let active_stack_for_stale = manager.get_active_stack().ok_or_else(|| {
CascadeError::config("No active stack. Create a stack first with 'ca stacks create'")
})?;
let stale_base = &active_stack_for_stale.base_branch;
let stale_current = repo.get_current_branch()?;
if stale_current != *stale_base {
match repo.get_commits_between(&stale_current, stale_base) {
Ok(base_ahead_commits) if !base_ahead_commits.is_empty() => {
let count = base_ahead_commits.len();
Output::warning(format!(
"Base branch '{}' has {} new commit(s) since your branch diverged",
stale_base, count
));
Output::sub_item("Commits from other developers may be included in your push.");
Output::tip("Run 'ca sync' or 'ca stacks rebase' to rebase first.");
if !dry_run && !yes {
let should_rebase = Confirm::with_theme(&ColorfulTheme::default())
.with_prompt("Rebase before pushing?")
.default(true)
.interact()
.map_err(|e| {
CascadeError::config(format!(
"Failed to get user confirmation: {e}"
))
})?;
if should_rebase {
Output::info(
"Run 'ca sync' to rebase your stack on the updated base branch.",
);
return Ok(());
}
}
}
_ => {} }
}
}
let commits_to_push = if let Some(commits_str) = commits {
commits_str
.split(',')
.map(|s| s.trim().to_string())
.collect::<Vec<String>>()
} else if let Some(since_ref) = since {
let since_commit = repo.resolve_reference(&since_ref)?;
let head_commit = repo.get_head_commit()?;
let commits = repo.get_commits_between(
&since_commit.id().to_string(),
&head_commit.id().to_string(),
)?;
commits.into_iter().map(|c| c.id().to_string()).collect()
} else if let Some(hash) = commit {
vec![hash]
} else {
let active_stack = manager.get_active_stack().ok_or_else(|| {
CascadeError::config("No active stack. Create a stack first with 'ca stacks create'")
})?;
let base_branch = &active_stack.base_branch;
let current_branch = repo.get_current_branch()?;
if current_branch == *base_branch {
let mut unpushed = Vec::new();
let head_commit = repo.get_head_commit()?;
let mut current_commit = head_commit;
loop {
let commit_hash = current_commit.id().to_string();
let already_in_stack = active_stack
.entries
.iter()
.any(|entry| entry.commit_hash == commit_hash);
if already_in_stack {
break;
}
unpushed.push(commit_hash);
if let Some(parent) = current_commit.parents().next() {
current_commit = parent;
} else {
break;
}
}
unpushed.reverse(); unpushed
} else {
match repo.get_commits_between(base_branch, ¤t_branch) {
Ok(commits) => {
let mut unpushed: Vec<String> =
commits.into_iter().map(|c| c.id().to_string()).collect();
unpushed.retain(|commit_hash| {
!active_stack
.entries
.iter()
.any(|entry| entry.commit_hash == *commit_hash)
});
unpushed.reverse(); unpushed
}
Err(e) => {
return Err(CascadeError::branch(format!(
"Failed to calculate commits between '{base_branch}' and '{current_branch}': {e}. \
This usually means the branches have diverged or don't share common history."
)));
}
}
}
};
if commits_to_push.is_empty() {
Output::info(" No commits to push to stack");
return Ok(());
}
let (user_name, user_email) = repo.get_user_info();
let mut has_foreign_commits = false;
Output::section(format!("Commits to push ({})", commits_to_push.len()));
for (i, commit_hash) in commits_to_push.iter().enumerate() {
let commit_obj = repo.get_commit(commit_hash)?;
let author = commit_obj.author();
let author_name = author.name().unwrap_or("unknown").to_string();
let author_email = author.email().unwrap_or("").to_string();
let summary = commit_obj.summary().unwrap_or("(no message)");
let short_hash = &commit_hash[..std::cmp::min(commit_hash.len(), 8)];
let is_foreign = !matches!(
(&user_name, &user_email),
(Some(ref un), _) if *un == author_name
) && !matches!(
(&user_name, &user_email),
(_, Some(ref ue)) if *ue == author_email
);
if is_foreign {
has_foreign_commits = true;
Output::numbered_item(
i + 1,
format!("{short_hash} {summary} [{author_name}] ← other author"),
);
} else {
Output::numbered_item(i + 1, format!("{short_hash} {summary} [{author_name}]"));
}
}
if has_foreign_commits {
let foreign_count = commits_to_push
.iter()
.filter(|hash| {
if let Ok(c) = repo.get_commit(hash) {
let a = c.author();
let an = a.name().unwrap_or("").to_string();
let ae = a.email().unwrap_or("").to_string();
!matches!(&user_name, Some(ref un) if *un == an)
&& !matches!(&user_email, Some(ref ue) if *ue == ae)
} else {
false
}
})
.count();
Output::warning(format!(
"{} commit(s) are from other authors — these may not be your changes.",
foreign_count
));
}
if dry_run {
Output::tip("Run without --dry-run to actually push these commits.");
return Ok(());
}
if !yes {
let default_confirm = !has_foreign_commits;
let should_continue = Confirm::with_theme(&ColorfulTheme::default())
.with_prompt(format!(
"Push {} commit(s) to stack?",
commits_to_push.len()
))
.default(default_confirm)
.interact()
.map_err(|e| CascadeError::config(format!("Failed to get user confirmation: {e}")))?;
if !should_continue {
Output::info("Push cancelled.");
return Ok(());
}
}
analyze_commits_for_safeguards(&commits_to_push, &repo, dry_run).await?;
let mut pushed_count = 0;
let mut source_branches = std::collections::HashSet::new();
for (i, commit_hash) in commits_to_push.iter().enumerate() {
let commit_obj = repo.get_commit(commit_hash)?;
let commit_msg = commit_obj.message().unwrap_or("").to_string();
let commit_source_branch = repo
.find_branch_containing_commit(commit_hash)
.unwrap_or_else(|_| current_branch.clone());
source_branches.insert(commit_source_branch.clone());
let branch_name = if i == 0 && branch.is_some() {
branch.clone().unwrap()
} else {
let temp_repo = GitRepository::open(&repo_root)?;
let branch_mgr = crate::git::BranchManager::new(temp_repo);
branch_mgr.generate_branch_name(&commit_msg)
};
let final_message = if i == 0 && message.is_some() {
message.clone().unwrap()
} else {
commit_msg.clone()
};
let entry_id = manager.push_to_stack(
branch_name.clone(),
commit_hash.clone(),
final_message.clone(),
commit_source_branch.clone(),
)?;
pushed_count += 1;
Output::success(format!(
"Pushed commit {}/{} to stack",
i + 1,
commits_to_push.len()
));
Output::sub_item(format!(
"Commit: {} ({})",
&commit_hash[..8],
commit_msg.split('\n').next().unwrap_or("")
));
Output::sub_item(format!("Branch: {branch_name}"));
Output::sub_item(format!("Source: {commit_source_branch}"));
Output::sub_item(format!("Entry ID: {entry_id}"));
println!();
}
if source_branches.len() > 1 {
Output::warning("Scattered Commit Detection");
Output::sub_item(format!(
"You've pushed commits from {} different Git branches:",
source_branches.len()
));
for branch in &source_branches {
Output::bullet(branch.to_string());
}
Output::section("This can lead to confusion because:");
Output::bullet("Stack appears sequential but commits are scattered across branches");
Output::bullet("Team members won't know which branch contains which work");
Output::bullet("Branch cleanup becomes unclear after merge");
Output::bullet("Rebase operations become more complex");
Output::tip("Consider consolidating work to a single feature branch:");
Output::bullet("Create a new feature branch: git checkout -b feature/consolidated-work");
Output::bullet("Cherry-pick commits in order: git cherry-pick <commit1> <commit2> ...");
Output::bullet("Delete old scattered branches");
Output::bullet("Push the consolidated branch to your stack");
println!();
}
Output::success(format!(
"Successfully pushed {} commit{} to stack",
pushed_count,
if pushed_count == 1 { "" } else { "s" }
));
Ok(())
}
async fn pop_from_stack(keep_branch: bool) -> Result<()> {
let current_dir = env::current_dir()
.map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
let repo_root = find_repository_root(¤t_dir)
.map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
let mut manager = StackManager::new(&repo_root)?;
let repo = GitRepository::open(&repo_root)?;
let entry = manager.pop_from_stack()?;
Output::success("Popped commit from stack");
Output::sub_item(format!(
"Commit: {} ({})",
entry.short_hash(),
entry.short_message(50)
));
Output::sub_item(format!("Branch: {}", entry.branch));
if !keep_branch && entry.branch != repo.get_current_branch()? {
match repo.delete_branch(&entry.branch) {
Ok(_) => Output::sub_item(format!("Deleted branch: {}", entry.branch)),
Err(e) => Output::warning(format!("Could not delete branch {}: {}", entry.branch, e)),
}
}
Ok(())
}
async fn submit_entry(
entry: Option<usize>,
title: Option<String>,
description: Option<String>,
range: Option<String>,
draft: bool,
open: bool,
) -> Result<()> {
let current_dir = env::current_dir()
.map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
let repo_root = find_repository_root(¤t_dir)
.map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
let stack_manager = StackManager::new(&repo_root)?;
let config_dir = crate::config::get_repo_config_dir(&repo_root)?;
let config_path = config_dir.join("config.json");
let settings = crate::config::Settings::load_from_file(&config_path)?;
let cascade_config = crate::config::CascadeConfig {
bitbucket: Some(settings.bitbucket.clone()),
git: settings.git.clone(),
auth: crate::config::AuthConfig::default(),
cascade: settings.cascade.clone(),
};
let active_stack = stack_manager.get_active_stack().ok_or_else(|| {
CascadeError::config("No active stack. Create a stack first with 'ca stack create'")
})?;
let stack_id = active_stack.id;
let entries_to_submit = if let Some(range_str) = range {
let mut entries = Vec::new();
if range_str.contains('-') {
let parts: Vec<&str> = range_str.split('-').collect();
if parts.len() != 2 {
return Err(CascadeError::config(
"Invalid range format. Use 'start-end' (e.g., '1-3')",
));
}
let start: usize = parts[0]
.parse()
.map_err(|_| CascadeError::config("Invalid start number in range"))?;
let end: usize = parts[1]
.parse()
.map_err(|_| CascadeError::config("Invalid end number in range"))?;
if start == 0
|| end == 0
|| start > active_stack.entries.len()
|| end > active_stack.entries.len()
{
return Err(CascadeError::config(format!(
"Range out of bounds. Stack has {} entries",
active_stack.entries.len()
)));
}
for i in start..=end {
entries.push((i, active_stack.entries[i - 1].clone()));
}
} else {
for entry_str in range_str.split(',') {
let entry_num: usize = entry_str.trim().parse().map_err(|_| {
CascadeError::config(format!("Invalid entry number: {entry_str}"))
})?;
if entry_num == 0 || entry_num > active_stack.entries.len() {
return Err(CascadeError::config(format!(
"Entry {} out of bounds. Stack has {} entries",
entry_num,
active_stack.entries.len()
)));
}
entries.push((entry_num, active_stack.entries[entry_num - 1].clone()));
}
}
entries
} else if let Some(entry_num) = entry {
if entry_num == 0 || entry_num > active_stack.entries.len() {
return Err(CascadeError::config(format!(
"Invalid entry number: {}. Stack has {} entries",
entry_num,
active_stack.entries.len()
)));
}
vec![(entry_num, active_stack.entries[entry_num - 1].clone())]
} else {
active_stack
.entries
.iter()
.enumerate()
.filter(|(_, entry)| !entry.is_submitted)
.map(|(i, entry)| (i + 1, entry.clone())) .collect::<Vec<(usize, _)>>()
};
if entries_to_submit.is_empty() {
Output::info("No entries to submit");
return Ok(());
}
Output::section(format!(
"Submitting {} {}",
entries_to_submit.len(),
if entries_to_submit.len() == 1 {
"entry"
} else {
"entries"
}
));
println!();
let integration_stack_manager = StackManager::new(&repo_root)?;
let mut integration =
BitbucketIntegration::new(integration_stack_manager, cascade_config.clone())?;
let mut submitted_count = 0;
let mut failed_entries = Vec::new();
let mut pr_urls = Vec::new(); let total_entries = entries_to_submit.len();
for (entry_num, entry_to_submit) in &entries_to_submit {
let tree_char = if entries_to_submit.len() == 1 {
"→"
} else if entry_num == &entries_to_submit.len() {
"└─"
} else {
"├─"
};
print!(
" {} Entry {}: {}... ",
tree_char, entry_num, entry_to_submit.branch
);
std::io::Write::flush(&mut std::io::stdout()).ok();
let entry_title = if total_entries == 1 {
title.clone()
} else {
None
};
let entry_description = if total_entries == 1 {
description.clone()
} else {
None
};
match integration
.submit_entry(
&stack_id,
&entry_to_submit.id,
entry_title,
entry_description,
draft,
)
.await
{
Ok(pr) => {
submitted_count += 1;
Output::success(format!("PR #{}", pr.id));
if let Some(url) = pr.web_url() {
use console::style;
Output::sub_item(format!(
"{} {} {}",
pr.from_ref.display_id,
style("→").dim(),
pr.to_ref.display_id
));
Output::sub_item(format!("URL: {}", style(url.clone()).cyan().underlined()));
pr_urls.push(url); }
}
Err(e) => {
Output::error("Failed");
let clean_error = if e.to_string().contains("non-fast-forward") {
"Branch has diverged (was rebased after initial submission). Update to v0.1.41+ to auto force-push.".to_string()
} else if e.to_string().contains("authentication") {
"Authentication failed. Check your Bitbucket credentials.".to_string()
} else {
e.to_string()
.lines()
.filter(|l| !l.trim().starts_with("hint:") && !l.trim().is_empty())
.take(1)
.collect::<Vec<_>>()
.join(" ")
.trim()
.to_string()
};
Output::sub_item(format!("Error: {}", clean_error));
failed_entries.push((*entry_num, clean_error));
}
}
}
println!();
let has_any_prs = active_stack
.entries
.iter()
.any(|e| e.pull_request_id.is_some());
if has_any_prs && submitted_count > 0 {
match integration.update_all_pr_descriptions(&stack_id).await {
Ok(updated_prs) => {
if !updated_prs.is_empty() {
Output::sub_item(format!(
"Updated {} PR description{} with stack hierarchy",
updated_prs.len(),
if updated_prs.len() == 1 { "" } else { "s" }
));
}
}
Err(e) => {
let error_msg = e.to_string();
if !error_msg.contains("409") && !error_msg.contains("out-of-date") {
let clean_error = error_msg.lines().next().unwrap_or("Unknown error").trim();
Output::warning(format!(
"Could not update some PR descriptions: {}",
clean_error
));
Output::sub_item(
"PRs were created successfully - descriptions can be updated manually",
);
}
}
}
}
if failed_entries.is_empty() {
Output::success(format!(
"{} {} submitted successfully!",
submitted_count,
if submitted_count == 1 {
"entry"
} else {
"entries"
}
));
} else {
println!();
Output::section("Submission Summary");
Output::success(format!("Successful: {submitted_count}"));
Output::error(format!("Failed: {}", failed_entries.len()));
if !failed_entries.is_empty() {
println!();
Output::tip("Retry failed entries:");
for (entry_num, _) in &failed_entries {
Output::bullet(format!("ca stack submit {entry_num}"));
}
}
}
if open && !pr_urls.is_empty() {
println!();
for url in &pr_urls {
if let Err(e) = open::that(url) {
Output::warning(format!("Could not open browser: {}", e));
Output::tip(format!("Open manually: {}", url));
}
}
}
Ok(())
}
async fn check_stack_status(name: Option<String>) -> Result<()> {
let current_dir = env::current_dir()
.map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
let repo_root = find_repository_root(¤t_dir)
.map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
let stack_manager = StackManager::new(&repo_root)?;
let config_dir = crate::config::get_repo_config_dir(&repo_root)?;
let config_path = config_dir.join("config.json");
let settings = crate::config::Settings::load_from_file(&config_path)?;
let cascade_config = crate::config::CascadeConfig {
bitbucket: Some(settings.bitbucket.clone()),
git: settings.git.clone(),
auth: crate::config::AuthConfig::default(),
cascade: settings.cascade.clone(),
};
let stack = if let Some(name) = name {
stack_manager
.get_stack_by_name(&name)
.ok_or_else(|| CascadeError::config(format!("Stack '{name}' not found")))?
} else {
stack_manager.get_active_stack().ok_or_else(|| {
CascadeError::config("No active stack. Use 'ca stack list' to see available stacks")
})?
};
let stack_id = stack.id;
Output::section(format!("Stack: {}", stack.name));
Output::sub_item(format!("ID: {}", stack.id));
Output::sub_item(format!("Base: {}", stack.base_branch));
if let Some(description) = &stack.description {
Output::sub_item(format!("Description: {description}"));
}
let integration = crate::bitbucket::BitbucketIntegration::new(stack_manager, cascade_config)?;
match integration.check_stack_status(&stack_id).await {
Ok(status) => {
Output::section("Pull Request Status");
Output::sub_item(format!("Total entries: {}", status.total_entries));
Output::sub_item(format!("Submitted: {}", status.submitted_entries));
Output::sub_item(format!("Open PRs: {}", status.open_prs));
Output::sub_item(format!("Merged PRs: {}", status.merged_prs));
Output::sub_item(format!("Declined PRs: {}", status.declined_prs));
Output::sub_item(format!(
"Completion: {:.1}%",
status.completion_percentage()
));
if !status.pull_requests.is_empty() {
Output::section("Pull Requests");
for pr in &status.pull_requests {
use console::style;
let state_icon = match pr.state {
crate::bitbucket::PullRequestState::Open => style("→").cyan().to_string(),
crate::bitbucket::PullRequestState::Merged => {
style("✓").green().to_string()
}
crate::bitbucket::PullRequestState::Declined => {
style("✗").red().to_string()
}
};
Output::bullet(format!(
"{} PR {}: {} ({} {} {})",
state_icon,
style(format!("#{}", pr.id)).dim(),
pr.title,
style(&pr.from_ref.display_id).dim(),
style("→").dim(),
style(&pr.to_ref.display_id).dim()
));
if let Some(url) = pr.web_url() {
println!(" URL: {}", style(url).cyan().underlined());
}
}
}
}
Err(e) => {
tracing::debug!("Failed to check stack status: {}", e);
return Err(e);
}
}
Ok(())
}
async fn list_pull_requests(state: Option<String>, verbose: bool) -> Result<()> {
let current_dir = env::current_dir()
.map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
let repo_root = find_repository_root(¤t_dir)
.map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
let stack_manager = StackManager::new(&repo_root)?;
let config_dir = crate::config::get_repo_config_dir(&repo_root)?;
let config_path = config_dir.join("config.json");
let settings = crate::config::Settings::load_from_file(&config_path)?;
let cascade_config = crate::config::CascadeConfig {
bitbucket: Some(settings.bitbucket.clone()),
git: settings.git.clone(),
auth: crate::config::AuthConfig::default(),
cascade: settings.cascade.clone(),
};
let integration = crate::bitbucket::BitbucketIntegration::new(stack_manager, cascade_config)?;
let pr_state = if let Some(state_str) = state {
match state_str.to_lowercase().as_str() {
"open" => Some(crate::bitbucket::PullRequestState::Open),
"merged" => Some(crate::bitbucket::PullRequestState::Merged),
"declined" => Some(crate::bitbucket::PullRequestState::Declined),
_ => {
return Err(CascadeError::config(format!(
"Invalid state '{state_str}'. Use: open, merged, declined"
)))
}
}
} else {
None
};
match integration.list_pull_requests(pr_state).await {
Ok(pr_page) => {
if pr_page.values.is_empty() {
Output::info("No pull requests found.");
return Ok(());
}
println!("Pull Requests ({} total):", pr_page.values.len());
for pr in &pr_page.values {
let state_icon = match pr.state {
crate::bitbucket::PullRequestState::Open => "○",
crate::bitbucket::PullRequestState::Merged => "✓",
crate::bitbucket::PullRequestState::Declined => "✗",
};
println!(" {} PR #{}: {}", state_icon, pr.id, pr.title);
if verbose {
println!(
" From: {} -> {}",
pr.from_ref.display_id, pr.to_ref.display_id
);
println!(
" Author: {}",
pr.author
.user
.display_name
.as_deref()
.unwrap_or(&pr.author.user.name)
);
if let Some(url) = pr.web_url() {
println!(" URL: {url}");
}
if let Some(desc) = &pr.description {
if !desc.is_empty() {
println!(" Description: {desc}");
}
}
println!();
}
}
if !verbose {
println!("\nUse --verbose for more details");
}
}
Err(e) => {
warn!("Failed to list pull requests: {}", e);
return Err(e);
}
}
Ok(())
}
async fn check_stack(_force: bool) -> Result<()> {
let current_dir = env::current_dir()
.map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
let repo_root = find_repository_root(¤t_dir)
.map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
let mut manager = StackManager::new(&repo_root)?;
let active_stack = manager
.get_active_stack()
.ok_or_else(|| CascadeError::config("No active stack"))?;
let stack_id = active_stack.id;
manager.sync_stack(&stack_id)?;
Output::success("Stack check completed successfully");
Ok(())
}
pub async fn continue_sync() -> Result<()> {
let current_dir = env::current_dir()
.map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
let repo_root = find_repository_root(¤t_dir)?;
Output::section("Continuing sync from where it left off");
println!();
let cherry_pick_head = crate::git::resolve_git_dir(&repo_root)?.join("CHERRY_PICK_HEAD");
if !cherry_pick_head.exists() {
return Err(CascadeError::config(
"No in-progress cherry-pick found. Nothing to continue.\n\n\
Use 'ca sync' to start a new sync."
.to_string(),
));
}
Output::info("Staging all resolved files");
std::process::Command::new("git")
.args(["add", "-A"])
.current_dir(&repo_root)
.output()
.map_err(CascadeError::Io)?;
let sync_state = crate::stack::SyncState::load(&repo_root).ok();
Output::info("Continuing cherry-pick");
let continue_output = std::process::Command::new("git")
.args(["cherry-pick", "--continue"])
.current_dir(&repo_root)
.output()
.map_err(CascadeError::Io)?;
if !continue_output.status.success() {
let stderr = String::from_utf8_lossy(&continue_output.stderr);
return Err(CascadeError::Branch(format!(
"Failed to continue cherry-pick: {}\n\n\
Make sure all conflicts are resolved.",
stderr
)));
}
Output::success("Cherry-pick continued successfully");
println!();
let git_repo = crate::git::GitRepository::open(&repo_root)?;
let current_branch = git_repo.get_current_branch()?;
let stack_branch = if let Some(state) = &sync_state {
if !state.current_entry_branch.is_empty() {
if !state.current_temp_branch.is_empty() && current_branch != state.current_temp_branch
{
tracing::warn!(
"Sync state temp branch '{}' differs from current branch '{}'",
state.current_temp_branch,
current_branch
);
}
state.current_entry_branch.clone()
} else if let Some(idx) = current_branch.rfind("-temp-") {
current_branch[..idx].to_string()
} else {
return Err(CascadeError::config(format!(
"Current branch '{}' doesn't appear to be a temp branch created by cascade.\n\
Expected format: <branch>-temp-<timestamp>",
current_branch
)));
}
} else if let Some(idx) = current_branch.rfind("-temp-") {
current_branch[..idx].to_string()
} else {
return Err(CascadeError::config(format!(
"Current branch '{}' doesn't appear to be a temp branch created by cascade.\n\
Expected format: <branch>-temp-<timestamp>",
current_branch
)));
};
Output::info(format!("Updating stack branch: {}", stack_branch));
let output = std::process::Command::new("git")
.args(["branch", "-f", &stack_branch])
.current_dir(&repo_root)
.output()
.map_err(CascadeError::Io)?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(CascadeError::validation(format!(
"Failed to update branch '{}': {}\n\n\
This could be due to:\n\
• Git lock file (.git/index.lock or .git/refs/heads/{}.lock)\n\
• Insufficient permissions\n\
• Branch is checked out in another worktree\n\n\
Recovery:\n\
1. Check for lock files: find .git -name '*.lock'\n\
2. Remove stale lock files if safe\n\
3. Run 'ca sync' to retry",
stack_branch,
stderr.trim(),
stack_branch
)));
}
let mut manager = crate::stack::StackManager::new(&repo_root)?;
let new_commit_hash = git_repo.get_branch_head(&stack_branch)?;
let (stack_id, entry_id_opt, working_branch) = if let Some(state) = &sync_state {
let stack_uuid = Uuid::parse_str(&state.stack_id)
.map_err(|e| CascadeError::config(format!("Invalid stack ID in sync state: {e}")))?;
let stack_snapshot = manager
.get_stack(&stack_uuid)
.cloned()
.ok_or_else(|| CascadeError::config("Stack not found in sync state".to_string()))?;
let working_branch = stack_snapshot
.working_branch
.clone()
.ok_or_else(|| CascadeError::config("Stack has no working branch".to_string()))?;
let entry_id = if !state.current_entry_id.is_empty() {
Uuid::parse_str(&state.current_entry_id).ok()
} else {
stack_snapshot
.entries
.iter()
.find(|e| e.branch == stack_branch)
.map(|e| e.id)
};
(stack_uuid, entry_id, working_branch)
} else {
let active_stack = manager
.get_active_stack()
.ok_or_else(|| CascadeError::config("No active stack found"))?;
let entry_id = active_stack
.entries
.iter()
.find(|e| e.branch == stack_branch)
.map(|e| e.id);
let working_branch = active_stack
.working_branch
.as_ref()
.ok_or_else(|| CascadeError::config("Active stack has no working branch"))?
.clone();
(active_stack.id, entry_id, working_branch)
};
let entry_id = entry_id_opt.ok_or_else(|| {
CascadeError::config(format!(
"Could not find stack entry for branch '{}'",
stack_branch
))
})?;
let stack = manager
.get_stack_mut(&stack_id)
.ok_or_else(|| CascadeError::config("Could not get mutable stack reference"))?;
stack
.update_entry_commit_hash(&entry_id, new_commit_hash.clone())
.map_err(CascadeError::config)?;
manager.save_to_disk()?;
let top_commit = {
let active_stack = manager
.get_active_stack()
.ok_or_else(|| CascadeError::config("No active stack found"))?;
if let Some(last_entry) = active_stack.entries.last() {
git_repo.get_branch_head(&last_entry.branch)?
} else {
new_commit_hash.clone()
}
};
Output::info(format!(
"Checking out to working branch: {}",
working_branch
));
git_repo.checkout_branch_unsafe(&working_branch)?;
if let Ok(working_head) = git_repo.get_branch_head(&working_branch) {
if working_head != top_commit {
git_repo.update_branch_to_commit(&working_branch, &top_commit)?;
git_repo.reset_to_head()?;
}
}
if sync_state.is_some() {
crate::stack::SyncState::delete(&repo_root)?;
}
println!();
Output::info("Resuming sync to complete the rebase...");
println!();
sync_stack(false, false, false).await
}
pub async fn abort_sync() -> Result<()> {
let current_dir = env::current_dir()
.map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
let repo_root = find_repository_root(¤t_dir)?;
Output::section("Aborting sync");
println!();
let cherry_pick_head = crate::git::resolve_git_dir(&repo_root)?.join("CHERRY_PICK_HEAD");
if !cherry_pick_head.exists() {
return Err(CascadeError::config(
"No in-progress cherry-pick found. Nothing to abort.\n\n\
The sync may have already completed or been aborted."
.to_string(),
));
}
Output::info("Aborting cherry-pick");
let abort_output = std::process::Command::new("git")
.args(["cherry-pick", "--abort"])
.env("CASCADE_SKIP_HOOKS", "1")
.current_dir(&repo_root)
.output()
.map_err(CascadeError::Io)?;
if !abort_output.status.success() {
let stderr = String::from_utf8_lossy(&abort_output.stderr);
return Err(CascadeError::Branch(format!(
"Failed to abort cherry-pick: {}",
stderr
)));
}
Output::success("Cherry-pick aborted");
let git_repo = crate::git::GitRepository::open(&repo_root)?;
if let Ok(state) = crate::stack::SyncState::load(&repo_root) {
println!();
Output::info("Cleaning up temporary branches");
for temp_branch in &state.temp_branches {
if let Err(e) = git_repo.delete_branch_unsafe(temp_branch) {
tracing::warn!("Could not delete temp branch '{}': {}", temp_branch, e);
}
}
Output::info(format!(
"Returning to original branch: {}",
state.original_branch
));
if let Err(e) = git_repo.checkout_branch_unsafe(&state.original_branch) {
tracing::warn!("Could not checkout original branch: {}", e);
if let Err(e2) = git_repo.checkout_branch_unsafe(&state.target_base) {
tracing::warn!("Could not checkout base branch: {}", e2);
}
}
crate::stack::SyncState::delete(&repo_root)?;
} else {
let current_branch = git_repo.get_current_branch()?;
if let Some(idx) = current_branch.rfind("-temp-") {
let original_branch = ¤t_branch[..idx];
Output::info(format!("Returning to branch: {}", original_branch));
if let Err(e) = git_repo.checkout_branch_unsafe(original_branch) {
tracing::warn!("Could not checkout original branch: {}", e);
}
if let Err(e) = git_repo.delete_branch_unsafe(¤t_branch) {
tracing::warn!("Could not delete temp branch: {}", e);
}
}
}
println!();
Output::success("Sync aborted");
println!();
Output::tip("You can start a fresh sync with: ca sync");
Ok(())
}
async fn sync_stack(force: bool, cleanup: bool, interactive: bool) -> Result<()> {
let current_dir = env::current_dir()
.map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
let repo_root = find_repository_root(¤t_dir)
.map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
let mut stack_manager = StackManager::new(&repo_root)?;
if stack_manager.is_in_edit_mode() {
debug!("Exiting edit mode before sync (commit SHAs will change)");
stack_manager.exit_edit_mode()?;
}
let git_repo = GitRepository::open(&repo_root)?;
if git_repo.is_dirty()? {
return Err(CascadeError::branch(
"Working tree has uncommitted changes. Commit or stash them before running 'ca sync'."
.to_string(),
));
}
let active_stack = stack_manager.get_active_stack().ok_or_else(|| {
CascadeError::config("No active stack. Create a stack first with 'ca stack create'")
})?;
let base_branch = active_stack.base_branch.clone();
let _stack_name = active_stack.name.clone();
let original_branch = git_repo.get_current_branch().ok();
match git_repo.update_local_branch_from_remote(&base_branch) {
Ok(_) => {}
Err(e) => {
if force {
Output::warning(format!(
"Failed to update base branch: {e} (continuing due to --force)"
));
} else {
let err_str = e.to_string();
let is_locked = err_str.contains("Locked") || err_str.contains("index is locked");
if is_locked {
Output::error(
"Git index is locked by another process (e.g. an IDE or Git GUI)",
);
Output::tip("Close it and re-run 'ca sync'");
return Err(CascadeError::branch(
"Git index locked — close the other process and retry".to_string(),
));
} else {
Output::error(format!("Failed to update base branch '{base_branch}': {e}"));
Output::tip("Use --force to skip update and continue with local state");
return Err(CascadeError::branch(format!(
"Failed to update '{base_branch}' from remote: {e}. Use --force to continue anyway."
)));
}
}
}
}
let mut updated_stack_manager = StackManager::new(&repo_root)?;
let stack_id = active_stack.id;
if let Some(stack) = updated_stack_manager.get_stack_mut(&stack_id) {
let mut updates = Vec::new();
for entry in &stack.entries {
if let Ok(current_commit) = git_repo.get_branch_head(&entry.branch) {
if entry.commit_hash != current_commit {
let is_safe_descendant = match git_repo.commit_exists(&entry.commit_hash) {
Ok(true) => {
match git_repo.is_descendant_of(¤t_commit, &entry.commit_hash) {
Ok(result) => result,
Err(e) => {
warn!(
"Cannot verify ancestry for '{}': {} - treating as unsafe to prevent potential data loss",
entry.branch, e
);
false
}
}
}
Ok(false) => {
debug!(
"Recorded commit {} for '{}' no longer exists in repository",
&entry.commit_hash[..8],
entry.branch
);
false
}
Err(e) => {
warn!(
"Cannot verify commit existence for '{}': {} - treating as unsafe to prevent potential data loss",
entry.branch, e
);
false
}
};
if is_safe_descendant {
debug!(
"Reconciling entry '{}': updating hash from {} to {} (current branch HEAD)",
entry.branch,
&entry.commit_hash[..8],
¤t_commit[..8]
);
updates.push((entry.id, current_commit));
} else {
warn!(
"Skipped automatic reconciliation for entry '{}' because local HEAD ({}) does not descend from recorded commit ({})",
entry.branch,
¤t_commit[..8],
&entry.commit_hash[..8]
);
}
}
}
}
for (entry_id, new_hash) in updates {
stack
.update_entry_commit_hash(&entry_id, new_hash)
.map_err(CascadeError::config)?;
}
updated_stack_manager.save_to_disk()?;
}
{
let config_dir = crate::config::get_repo_config_dir(&repo_root)?;
let config_path = config_dir.join("config.json");
if let Ok(settings) = crate::config::Settings::load_from_file(&config_path) {
let cascade_config = crate::config::CascadeConfig {
bitbucket: Some(settings.bitbucket.clone()),
git: settings.git.clone(),
auth: crate::config::AuthConfig::default(),
cascade: settings.cascade.clone(),
};
if let Ok(mut integration) = crate::bitbucket::BitbucketIntegration::new(
StackManager::new(&repo_root)?,
cascade_config,
) {
let _ = integration.check_enhanced_stack_status(&stack_id).await;
updated_stack_manager = StackManager::new(&repo_root)?;
}
}
}
match updated_stack_manager.sync_stack(&stack_id) {
Ok(_) => {
if let Some(updated_stack) = updated_stack_manager.get_stack(&stack_id) {
if updated_stack.entries.is_empty() {
println!(); Output::info("Stack has no entries yet");
Output::tip("Use 'ca push' to add commits to this stack");
return Ok(());
}
match &updated_stack.status {
crate::stack::StackStatus::NeedsSync => {
let config_dir = crate::config::get_repo_config_dir(&repo_root)?;
let config_path = config_dir.join("config.json");
let settings = crate::config::Settings::load_from_file(&config_path)?;
let cascade_config = crate::config::CascadeConfig {
bitbucket: Some(settings.bitbucket.clone()),
git: settings.git.clone(),
auth: crate::config::AuthConfig::default(),
cascade: settings.cascade.clone(),
};
println!();
let options = crate::stack::RebaseOptions {
strategy: crate::stack::RebaseStrategy::ForcePush,
interactive,
target_base: Some(base_branch.clone()),
preserve_merges: true,
auto_resolve: !interactive, max_retries: 3,
skip_pull: Some(true), original_working_branch: original_branch.clone(), };
let mut rebase_manager = crate::stack::RebaseManager::new(
updated_stack_manager,
git_repo,
options,
);
let rebase_result = rebase_manager.rebase_stack(&stack_id);
match rebase_result {
Ok(result) => {
if !result.branch_mapping.is_empty() {
if let Some(ref _bitbucket_config) = cascade_config.bitbucket {
let integration_stack_manager =
StackManager::new(&repo_root)?;
match crate::bitbucket::BitbucketIntegration::new(
integration_stack_manager,
cascade_config,
) {
Ok(mut integration) => {
let pr_result = integration
.update_prs_after_rebase(
&stack_id,
&result.branch_mapping,
)
.await;
match pr_result {
Ok(updated_prs) => {
if !updated_prs.is_empty() {
Output::success(format!(
"Updated {} pull request{}",
updated_prs.len(),
if updated_prs.len() == 1 {
""
} else {
"s"
}
));
}
}
Err(e) => {
Output::warning(format!(
"Failed to update pull requests: {e}"
));
}
}
}
Err(e) => {
tracing::debug!(
"Skipping PR updates (Bitbucket not configured): {e}"
);
}
}
}
}
}
Err(e) => {
return Err(e);
}
}
}
crate::stack::StackStatus::Clean => {
}
other => {
Output::info(format!("Stack status: {other:?}"));
}
}
}
}
Err(e) => {
if force {
Output::warning(format!(
"Failed to check stack status: {e} (continuing due to --force)"
));
} else {
if let Some(ref branch) = original_branch {
if branch != &base_branch {
if let Err(restore_err) = git_repo.checkout_branch_silent(branch) {
Output::warning(format!(
"Could not restore original branch '{}': {}",
branch, restore_err
));
}
}
}
return Err(e);
}
}
}
if cleanup {
let git_repo_for_cleanup = GitRepository::open(&repo_root)?;
match perform_simple_cleanup(&stack_manager, &git_repo_for_cleanup, false).await {
Ok(result) => {
if result.total_candidates > 0 {
Output::section("Cleanup Summary");
if !result.cleaned_branches.is_empty() {
Output::success(format!(
"Cleaned up {} merged branches",
result.cleaned_branches.len()
));
for branch in &result.cleaned_branches {
Output::sub_item(format!("🗑️ Deleted: {branch}"));
}
}
if !result.skipped_branches.is_empty() {
Output::sub_item(format!(
"Skipped {} branches",
result.skipped_branches.len()
));
}
if !result.failed_branches.is_empty() {
for (branch, error) in &result.failed_branches {
Output::warning(format!("Failed to clean up {branch}: {error}"));
}
}
}
}
Err(e) => {
Output::warning(format!("Branch cleanup failed: {e}"));
}
}
}
Output::success("Sync completed successfully!");
Ok(())
}
async fn rebase_stack(
interactive: bool,
onto: Option<String>,
strategy: Option<RebaseStrategyArg>,
) -> Result<()> {
let current_dir = env::current_dir()
.map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
let repo_root = find_repository_root(¤t_dir)
.map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
let stack_manager = StackManager::new(&repo_root)?;
let git_repo = GitRepository::open(&repo_root)?;
let config_dir = crate::config::get_repo_config_dir(&repo_root)?;
let config_path = config_dir.join("config.json");
let settings = crate::config::Settings::load_from_file(&config_path)?;
let cascade_config = crate::config::CascadeConfig {
bitbucket: Some(settings.bitbucket.clone()),
git: settings.git.clone(),
auth: crate::config::AuthConfig::default(),
cascade: settings.cascade.clone(),
};
let active_stack = stack_manager.get_active_stack().ok_or_else(|| {
CascadeError::config("No active stack. Create a stack first with 'ca stack create'")
})?;
let stack_id = active_stack.id;
let active_stack = stack_manager
.get_stack(&stack_id)
.ok_or_else(|| CascadeError::config("Active stack not found"))?
.clone();
if active_stack.entries.is_empty() {
Output::info("Stack is empty. Nothing to rebase.");
return Ok(());
}
let rebase_strategy = if let Some(cli_strategy) = strategy {
match cli_strategy {
RebaseStrategyArg::ForcePush => crate::stack::RebaseStrategy::ForcePush,
RebaseStrategyArg::Interactive => crate::stack::RebaseStrategy::Interactive,
}
} else {
crate::stack::RebaseStrategy::ForcePush
};
let original_branch = git_repo.get_current_branch().ok();
debug!(" Strategy: {:?}", rebase_strategy);
debug!(" Interactive: {}", interactive);
debug!(" Target base: {:?}", onto);
debug!(" Entries: {}", active_stack.entries.len());
println!();
let rebase_spinner = crate::utils::spinner::Spinner::new_with_output_below(format!(
"Rebasing stack: {}",
active_stack.name
));
let options = crate::stack::RebaseOptions {
strategy: rebase_strategy.clone(),
interactive,
target_base: onto,
preserve_merges: true,
auto_resolve: !interactive, max_retries: 3,
skip_pull: None, original_working_branch: original_branch,
};
let mut rebase_manager = crate::stack::RebaseManager::new(stack_manager, git_repo, options);
if rebase_manager.is_rebase_in_progress() {
Output::warning("Rebase already in progress!");
Output::tip("Use 'git status' to check the current state");
Output::next_steps(&[
"Run 'ca stack continue-rebase' to continue",
"Run 'ca stack abort-rebase' to abort",
]);
rebase_spinner.stop();
return Ok(());
}
let rebase_result = rebase_manager.rebase_stack(&stack_id);
rebase_spinner.stop();
println!();
match rebase_result {
Ok(result) => {
Output::success("Rebase completed!");
Output::sub_item(result.get_summary());
if result.has_conflicts() {
Output::warning(format!(
"{} conflicts were resolved",
result.conflicts.len()
));
for conflict in &result.conflicts {
Output::bullet(&conflict[..8.min(conflict.len())]);
}
}
if !result.branch_mapping.is_empty() {
Output::section("Branch mapping");
for (old, new) in &result.branch_mapping {
Output::bullet(format!("{old} -> {new}"));
}
if let Some(ref _bitbucket_config) = cascade_config.bitbucket {
let integration_stack_manager = StackManager::new(&repo_root)?;
let mut integration = BitbucketIntegration::new(
integration_stack_manager,
cascade_config.clone(),
)?;
match integration
.update_prs_after_rebase(&stack_id, &result.branch_mapping)
.await
{
Ok(updated_prs) => {
if !updated_prs.is_empty() {
println!(" 🔄 Preserved pull request history:");
for pr_update in updated_prs {
println!(" ✅ {pr_update}");
}
}
}
Err(e) => {
Output::warning(format!("Failed to update pull requests: {e}"));
Output::sub_item("You may need to manually update PRs in Bitbucket");
}
}
}
}
Output::success(format!(
"{} commits successfully rebased",
result.success_count()
));
if matches!(rebase_strategy, crate::stack::RebaseStrategy::ForcePush) {
println!();
Output::section("Next steps");
if !result.branch_mapping.is_empty() {
Output::numbered_item(1, "Branches have been rebased and force-pushed");
Output::numbered_item(
2,
"Pull requests updated automatically (history preserved)",
);
Output::numbered_item(3, "Review the updated PRs in Bitbucket");
Output::numbered_item(4, "Test your changes");
} else {
println!(" 1. Review the rebased stack");
println!(" 2. Test your changes");
println!(" 3. Submit new pull requests with 'ca stack submit'");
}
}
}
Err(e) => {
warn!("❌ Rebase failed: {}", e);
Output::tip(" Tips for resolving rebase issues:");
println!(" - Check for uncommitted changes with 'git status'");
println!(" - Ensure base branch is up to date");
println!(" - Try interactive mode: 'ca stack rebase --interactive'");
return Err(e);
}
}
Ok(())
}
pub async fn continue_rebase() -> Result<()> {
let current_dir = env::current_dir()
.map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
let repo_root = find_repository_root(¤t_dir)
.map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
let stack_manager = StackManager::new(&repo_root)?;
let git_repo = crate::git::GitRepository::open(&repo_root)?;
let options = crate::stack::RebaseOptions::default();
let rebase_manager = crate::stack::RebaseManager::new(stack_manager, git_repo, options);
if !rebase_manager.is_rebase_in_progress() {
Output::info(" No rebase in progress");
return Ok(());
}
println!(" Continuing rebase...");
match rebase_manager.continue_rebase() {
Ok(_) => {
Output::success(" Rebase continued successfully");
println!(" Check 'ca stack rebase-status' for current state");
}
Err(e) => {
warn!("❌ Failed to continue rebase: {}", e);
Output::tip(" You may need to resolve conflicts first:");
println!(" 1. Edit conflicted files");
println!(" 2. Stage resolved files with 'git add'");
println!(" 3. Run 'ca stack continue-rebase' again");
}
}
Ok(())
}
pub async fn abort_rebase() -> Result<()> {
let current_dir = env::current_dir()
.map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
let repo_root = find_repository_root(¤t_dir)
.map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
let stack_manager = StackManager::new(&repo_root)?;
let git_repo = crate::git::GitRepository::open(&repo_root)?;
let options = crate::stack::RebaseOptions::default();
let rebase_manager = crate::stack::RebaseManager::new(stack_manager, git_repo, options);
if !rebase_manager.is_rebase_in_progress() {
Output::info(" No rebase in progress");
return Ok(());
}
Output::warning("Aborting rebase...");
match rebase_manager.abort_rebase() {
Ok(_) => {
Output::success(" Rebase aborted successfully");
println!(" Repository restored to pre-rebase state");
}
Err(e) => {
warn!("❌ Failed to abort rebase: {}", e);
println!("⚠️ You may need to manually clean up the repository state");
}
}
Ok(())
}
async fn rebase_status() -> Result<()> {
let current_dir = env::current_dir()
.map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
let repo_root = find_repository_root(¤t_dir)
.map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
let stack_manager = StackManager::new(&repo_root)?;
let git_repo = crate::git::GitRepository::open(&repo_root)?;
println!("Rebase Status");
let git_dir = git_repo.git_dir();
let rebase_in_progress = git_dir.join("REBASE_HEAD").exists()
|| git_dir.join("rebase-merge").exists()
|| git_dir.join("rebase-apply").exists();
if rebase_in_progress {
println!(" Status: 🔄 Rebase in progress");
println!(
"
📝 Actions available:"
);
println!(" - 'ca stack continue-rebase' to continue");
println!(" - 'ca stack abort-rebase' to abort");
println!(" - 'git status' to see conflicted files");
match git_repo.get_status() {
Ok(statuses) => {
let mut conflicts = Vec::new();
for status in statuses.iter() {
if status.status().contains(git2::Status::CONFLICTED) {
if let Some(path) = status.path() {
conflicts.push(path.to_string());
}
}
}
if !conflicts.is_empty() {
println!(" ⚠️ Conflicts in {} files:", conflicts.len());
for conflict in conflicts {
println!(" - {conflict}");
}
println!(
"
💡 To resolve conflicts:"
);
println!(" 1. Edit the conflicted files");
println!(" 2. Stage resolved files: git add <file>");
println!(" 3. Continue: ca stack continue-rebase");
}
}
Err(e) => {
warn!("Failed to get git status: {}", e);
}
}
} else {
println!(" Status: ✅ No rebase in progress");
if let Some(active_stack) = stack_manager.get_active_stack() {
println!(" Active stack: {}", active_stack.name);
println!(" Entries: {}", active_stack.entries.len());
println!(" Base branch: {}", active_stack.base_branch);
}
}
Ok(())
}
async fn delete_stack(name: String, force: bool) -> Result<()> {
let current_dir = env::current_dir()
.map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
let repo_root = find_repository_root(¤t_dir)
.map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
let mut manager = StackManager::new(&repo_root)?;
let stack = manager
.get_stack_by_name(&name)
.ok_or_else(|| CascadeError::config(format!("Stack '{name}' not found")))?;
let stack_id = stack.id;
if !force && !stack.entries.is_empty() {
return Err(CascadeError::config(format!(
"Stack '{}' has {} entries. Use --force to delete anyway",
name,
stack.entries.len()
)));
}
let deleted = manager.delete_stack(&stack_id)?;
Output::success(format!("Deleted stack '{}'", deleted.name));
if !deleted.entries.is_empty() {
Output::warning(format!("{} entries were removed", deleted.entries.len()));
}
Ok(())
}
async fn validate_stack(name: Option<String>, fix_mode: Option<String>) -> Result<()> {
let current_dir = env::current_dir()
.map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
let repo_root = find_repository_root(¤t_dir)
.map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
let mut manager = StackManager::new(&repo_root)?;
if let Some(name) = name {
let stack = manager
.get_stack_by_name(&name)
.ok_or_else(|| CascadeError::config(format!("Stack '{name}' not found")))?;
let stack_id = stack.id;
match stack.validate() {
Ok(_message) => {
Output::success(format!("Stack '{}' structure validation passed", name));
}
Err(e) => {
Output::error(format!(
"Stack '{}' structure validation failed: {}",
name, e
));
return Err(CascadeError::config(e));
}
}
manager.handle_branch_modifications(&stack_id, fix_mode)?;
println!();
Output::success(format!("Stack '{name}' validation completed"));
Ok(())
} else {
Output::section("Validating all stacks");
println!();
let all_stacks = manager.get_all_stacks();
let stack_ids: Vec<uuid::Uuid> = all_stacks.iter().map(|s| s.id).collect();
if stack_ids.is_empty() {
Output::info("No stacks found");
return Ok(());
}
let mut all_valid = true;
for stack_id in stack_ids {
let stack = manager.get_stack(&stack_id).unwrap();
let stack_name = &stack.name;
println!("Checking stack '{stack_name}':");
match stack.validate() {
Ok(message) => {
Output::sub_item(format!("Structure: {message}"));
}
Err(e) => {
Output::sub_item(format!("Structure: {e}"));
all_valid = false;
continue;
}
}
match manager.handle_branch_modifications(&stack_id, fix_mode.clone()) {
Ok(_) => {
Output::sub_item("Git integrity: OK");
}
Err(e) => {
Output::sub_item(format!("Git integrity: {e}"));
all_valid = false;
}
}
println!();
}
if all_valid {
Output::success("All stacks passed validation");
} else {
Output::warning("Some stacks have validation issues");
return Err(CascadeError::config("Stack validation failed".to_string()));
}
Ok(())
}
}
#[allow(dead_code)]
fn get_unpushed_commits(repo: &GitRepository, stack: &crate::stack::Stack) -> Result<Vec<String>> {
let mut unpushed = Vec::new();
let head_commit = repo.get_head_commit()?;
let mut current_commit = head_commit;
loop {
let commit_hash = current_commit.id().to_string();
let already_in_stack = stack
.entries
.iter()
.any(|entry| entry.commit_hash == commit_hash);
if already_in_stack {
break;
}
unpushed.push(commit_hash);
if let Some(parent) = current_commit.parents().next() {
current_commit = parent;
} else {
break;
}
}
unpushed.reverse(); Ok(unpushed)
}
pub async fn squash_commits(
repo: &GitRepository,
count: usize,
since_ref: Option<String>,
) -> Result<()> {
if count <= 1 {
return Ok(()); }
let _current_branch = repo.get_current_branch()?;
let rebase_range = if let Some(ref since) = since_ref {
since.clone()
} else {
format!("HEAD~{count}")
};
println!(" Analyzing {count} commits to create smart squash message...");
let head_commit = repo.get_head_commit()?;
let mut commits_to_squash = Vec::new();
let mut current = head_commit;
for _ in 0..count {
commits_to_squash.push(current.clone());
if current.parent_count() > 0 {
current = current.parent(0).map_err(CascadeError::Git)?;
} else {
break;
}
}
let smart_message = generate_squash_message(&commits_to_squash)?;
println!(
" Smart message: {}",
smart_message.lines().next().unwrap_or("")
);
let reset_target = if since_ref.is_some() {
format!("{rebase_range}~1")
} else {
format!("HEAD~{count}")
};
repo.reset_soft(&reset_target)?;
repo.stage_all()?;
let new_commit_hash = repo.commit(&smart_message)?;
println!(
" Created squashed commit: {} ({})",
&new_commit_hash[..8],
smart_message.lines().next().unwrap_or("")
);
println!(" 💡 Tip: Use 'git commit --amend' to edit the commit message if needed");
Ok(())
}
pub fn generate_squash_message(commits: &[git2::Commit]) -> Result<String> {
if commits.is_empty() {
return Ok("Squashed commits".to_string());
}
let messages: Vec<String> = commits
.iter()
.map(|c| c.message().unwrap_or("").trim().to_string())
.filter(|m| !m.is_empty())
.collect();
if messages.is_empty() {
return Ok("Squashed commits".to_string());
}
if let Some(last_msg) = messages.first() {
if last_msg.starts_with("Final:") || last_msg.starts_with("final:") {
return Ok(last_msg
.trim_start_matches("Final:")
.trim_start_matches("final:")
.trim()
.to_string());
}
}
let wip_count = messages
.iter()
.filter(|m| {
m.to_lowercase().starts_with("wip") || m.to_lowercase().contains("work in progress")
})
.count();
if wip_count > messages.len() / 2 {
let non_wip: Vec<&String> = messages
.iter()
.filter(|m| {
!m.to_lowercase().starts_with("wip")
&& !m.to_lowercase().contains("work in progress")
})
.collect();
if let Some(best_msg) = non_wip.first() {
return Ok(best_msg.to_string());
}
let feature = extract_feature_from_wip(&messages);
return Ok(feature);
}
Ok(messages.first().unwrap().clone())
}
pub fn extract_feature_from_wip(messages: &[String]) -> String {
for msg in messages {
if msg.to_lowercase().starts_with("wip:") {
if let Some(rest) = msg
.strip_prefix("WIP:")
.or_else(|| msg.strip_prefix("wip:"))
{
let feature = rest.trim();
if !feature.is_empty() && feature.len() > 3 {
let mut chars: Vec<char> = feature.chars().collect();
if let Some(first) = chars.first_mut() {
*first = first.to_uppercase().next().unwrap_or(*first);
}
return chars.into_iter().collect();
}
}
}
}
if let Some(first) = messages.first() {
let cleaned = first
.trim_start_matches("WIP:")
.trim_start_matches("wip:")
.trim_start_matches("WIP")
.trim_start_matches("wip")
.trim();
if !cleaned.is_empty() {
return format!("Implement {cleaned}");
}
}
format!("Squashed {} commits", messages.len())
}
pub fn count_commits_since(repo: &GitRepository, since_commit_hash: &str) -> Result<usize> {
let head_commit = repo.get_head_commit()?;
let since_commit = repo.get_commit(since_commit_hash)?;
let mut count = 0;
let mut current = head_commit;
loop {
if current.id() == since_commit.id() {
break;
}
count += 1;
if current.parent_count() == 0 {
break; }
current = current.parent(0).map_err(CascadeError::Git)?;
}
Ok(count)
}
async fn land_stack(
entry: Option<usize>,
force: bool,
dry_run: bool,
auto: bool,
wait_for_builds: bool,
strategy: Option<MergeStrategyArg>,
build_timeout: u64,
) -> Result<()> {
let current_dir = env::current_dir()
.map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
let repo_root = find_repository_root(¤t_dir)
.map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
let stack_manager = StackManager::new(&repo_root)?;
let stack_id = stack_manager
.get_active_stack()
.map(|s| s.id)
.ok_or_else(|| {
CascadeError::config(
"No active stack. Use 'ca stack create' or 'ca stack switch' to select a stack"
.to_string(),
)
})?;
let active_stack = stack_manager
.get_active_stack()
.cloned()
.ok_or_else(|| CascadeError::config("No active stack found".to_string()))?;
let config_dir = crate::config::get_repo_config_dir(&repo_root)?;
let config_path = config_dir.join("config.json");
let settings = crate::config::Settings::load_from_file(&config_path)?;
let cascade_config = crate::config::CascadeConfig {
bitbucket: Some(settings.bitbucket.clone()),
git: settings.git.clone(),
auth: crate::config::AuthConfig::default(),
cascade: settings.cascade.clone(),
};
let mut integration =
crate::bitbucket::BitbucketIntegration::new(stack_manager, cascade_config)?;
let mut status = integration.check_enhanced_stack_status(&stack_id).await?;
let advisory_patterns = &settings.cascade.advisory_merge_checks;
if !advisory_patterns.is_empty() {
for enhanced in &mut status.enhanced_statuses {
enhanced.apply_advisory_filters(advisory_patterns);
}
}
if status.enhanced_statuses.is_empty() {
Output::error("No pull requests found to land");
return Ok(());
}
let ready_prs: Vec<_> = status
.enhanced_statuses
.iter()
.filter(|pr_status| {
if let Some(entry_num) = entry {
if let Some(stack_entry) = active_stack.entries.get(entry_num.saturating_sub(1)) {
if pr_status.pr.from_ref.display_id != stack_entry.branch {
return false;
}
} else {
return false; }
}
if force {
pr_status.pr.state == crate::bitbucket::pull_request::PullRequestState::Open
} else {
pr_status.is_ready_to_land()
}
})
.collect();
if ready_prs.is_empty() {
if let Some(entry_num) = entry {
Output::error(format!(
"Entry {entry_num} is not ready to land or doesn't exist"
));
} else {
Output::error("No pull requests are ready to land");
}
println!();
Output::section("Blocking Issues");
for pr_status in &status.enhanced_statuses {
if pr_status.pr.state == crate::bitbucket::pull_request::PullRequestState::Open {
let blocking = pr_status.get_blocking_reasons();
if !blocking.is_empty() {
Output::sub_item(format!("PR #{}: {}", pr_status.pr.id, blocking.join(", ")));
}
}
}
if !force {
println!();
Output::tip("Use --force to land PRs with blocking issues (dangerous!)");
}
return Ok(());
}
if dry_run {
if let Some(entry_num) = entry {
Output::section(format!("Dry Run - Entry {entry_num} that would be landed"));
} else {
Output::section("Dry Run - PRs that would be landed");
}
for pr_status in &ready_prs {
Output::sub_item(format!("PR #{}: {}", pr_status.pr.id, pr_status.pr.title));
if !pr_status.is_ready_to_land() && force {
let blocking = pr_status.get_blocking_reasons();
Output::warning(format!("Would force land despite: {}", blocking.join(", ")));
}
}
return Ok(());
}
if let Some(entry_num) = entry {
if ready_prs.len() > 1 {
Output::info(format!(
"{} PRs are ready to land, but landing only entry #{}",
ready_prs.len(),
entry_num
));
}
}
let merge_strategy: crate::bitbucket::pull_request::MergeStrategy =
strategy.unwrap_or(MergeStrategyArg::Squash).into();
let auto_merge_conditions = crate::bitbucket::pull_request::AutoMergeConditions {
merge_strategy: merge_strategy.clone(),
wait_for_builds,
build_timeout: std::time::Duration::from_secs(build_timeout),
allowed_authors: None, };
println!();
Output::section(format!(
"Landing {} PR{}",
ready_prs.len(),
if ready_prs.len() == 1 { "" } else { "s" }
));
let pr_manager = crate::bitbucket::pull_request::PullRequestManager::new(
crate::bitbucket::BitbucketClient::new(&settings.bitbucket)?,
);
let mut landed_count = 0;
let mut failed_count = 0;
let total_ready_prs = ready_prs.len();
for pr_status in ready_prs {
let pr_id = pr_status.pr.id;
Output::progress(format!("Landing PR #{}: {}", pr_id, pr_status.pr.title));
let land_result = if auto {
pr_manager
.auto_merge_if_ready(pr_id, &auto_merge_conditions)
.await
} else {
pr_manager
.merge_pull_request(pr_id, merge_strategy.clone())
.await
.map(
|pr| crate::bitbucket::pull_request::AutoMergeResult::Merged {
pr: Box::new(pr),
merge_strategy: merge_strategy.clone(),
},
)
};
match land_result {
Ok(crate::bitbucket::pull_request::AutoMergeResult::Merged { .. }) => {
Output::success_inline();
landed_count += 1;
let merged_branch = &pr_status.pr.from_ref.display_id;
if let Some(landed_entry) = active_stack
.entries
.iter()
.find(|e| e.branch == *merged_branch)
{
let landed_entry_id = landed_entry.id;
let mut mark_manager = StackManager::new(&repo_root)?;
let _ = mark_manager.set_entry_merged(&stack_id, &landed_entry_id, true);
}
if landed_count < total_ready_prs {
Output::sub_item("Retargeting remaining PRs to latest base");
let base_branch = active_stack.base_branch.clone();
let git_repo = crate::git::GitRepository::open(&repo_root)?;
Output::sub_item(format!("Updating base branch: {base_branch}"));
match git_repo.pull(&base_branch) {
Ok(_) => Output::sub_item("Base branch updated"),
Err(e) => {
Output::warning(format!("Failed to update base branch: {e}"));
Output::tip(format!(
"You may want to manually run: git pull origin {base_branch}"
));
}
}
let temp_manager = StackManager::new(&repo_root)?;
let stack_for_count = temp_manager
.get_stack(&stack_id)
.ok_or_else(|| CascadeError::config("Stack not found"))?;
let entry_count = stack_for_count.entries.len();
let plural = if entry_count == 1 { "entry" } else { "entries" };
println!(); let rebase_spinner = crate::utils::spinner::Spinner::new(format!(
"Retargeting {} {}",
entry_count, plural
));
let mut rebase_manager = crate::stack::RebaseManager::new(
StackManager::new(&repo_root)?,
git_repo,
crate::stack::RebaseOptions {
strategy: crate::stack::RebaseStrategy::ForcePush,
target_base: Some(base_branch.clone()),
..Default::default()
},
);
let rebase_result = rebase_manager.rebase_stack(&stack_id);
rebase_spinner.stop();
println!();
match rebase_result {
Ok(rebase_result) => {
if !rebase_result.branch_mapping.is_empty() {
let retarget_config = crate::config::CascadeConfig {
bitbucket: Some(settings.bitbucket.clone()),
git: settings.git.clone(),
auth: crate::config::AuthConfig::default(),
cascade: settings.cascade.clone(),
};
let mut retarget_integration = BitbucketIntegration::new(
StackManager::new(&repo_root)?,
retarget_config,
)?;
match retarget_integration
.update_prs_after_rebase(
&stack_id,
&rebase_result.branch_mapping,
)
.await
{
Ok(updated_prs) => {
if !updated_prs.is_empty() {
Output::sub_item(format!(
"Updated {} PRs with new targets",
updated_prs.len()
));
}
}
Err(e) => {
Output::warning(format!(
"Failed to update remaining PRs: {e}"
));
Output::tip(format!("You may need to run: ca stack rebase --onto {base_branch}"));
}
}
}
}
Err(e) => {
println!();
Output::error("Rebase conflict while retargeting remaining PRs");
println!();
Output::section("To resolve and continue");
Output::numbered_item(1, "Resolve conflicts in the affected files");
Output::numbered_item(2, "Stage resolved files: git add <files>");
Output::numbered_item(3, "Finish the rebase: ca sync continue");
Output::numbered_item(4, "Or abort the rebase: ca sync abort");
println!();
Output::tip(
"Once the rebase is complete, re-run 'ca land' to continue merging remaining PRs",
);
Output::sub_item(format!("Error details: {e}"));
break;
}
}
}
}
Ok(crate::bitbucket::pull_request::AutoMergeResult::NotReady { blocking_reasons }) => {
Output::error_inline(format!("Not ready: {}", blocking_reasons.join(", ")));
failed_count += 1;
if !force {
break;
}
}
Ok(crate::bitbucket::pull_request::AutoMergeResult::Failed { error }) => {
Output::error_inline(format!("Failed: {error}"));
failed_count += 1;
if !force {
break;
}
}
Err(e) => {
Output::error_inline("");
Output::error(format!("Failed to land PR #{pr_id}: {e}"));
failed_count += 1;
if !force {
break;
}
}
}
}
println!();
Output::section("Landing Summary");
Output::sub_item(format!("Successfully landed: {landed_count}"));
if failed_count > 0 {
Output::sub_item(format!("Failed to land: {failed_count}"));
}
if landed_count > 0 {
Output::success("Landing operation completed!");
let final_stack_manager = StackManager::new(&repo_root)?;
if let Some(final_stack) = final_stack_manager.get_stack(&stack_id) {
let all_merged = final_stack.entries.iter().all(|entry| entry.is_merged);
if all_merged && !final_stack.entries.is_empty() {
println!();
Output::success("All PRs in stack merged!");
println!();
let base_branch = active_stack.base_branch.clone();
let deactivate_repo = GitRepository::open(&repo_root)?;
match deactivate_repo.checkout_branch(&base_branch) {
Ok(_) => {
Output::sub_item(format!("Checked out base branch: {base_branch}"));
}
Err(e) => {
Output::warning(format!(
"Could not checkout base branch '{base_branch}': {e}"
));
}
}
if !dry_run {
let should_cleanup = Confirm::with_theme(&ColorfulTheme::default())
.with_prompt("Clean up merged branches?")
.default(true)
.interact()
.unwrap_or(false);
if should_cleanup {
let cleanup_git_repo = GitRepository::open(&repo_root)?;
let mut cleanup_manager = CleanupManager::new(
StackManager::new(&repo_root)?,
cleanup_git_repo,
CleanupOptions {
dry_run: false,
force: true,
include_stale: false,
cleanup_remote: false,
stale_threshold_days: 30,
cleanup_non_stack: false,
},
);
match cleanup_manager.find_cleanup_candidates() {
Ok(candidates) => {
let stack_candidates: Vec<_> = candidates
.into_iter()
.filter(|c| c.stack_id == Some(stack_id))
.collect();
if !stack_candidates.is_empty() {
match cleanup_manager.perform_cleanup(&stack_candidates) {
Ok(cleanup_result) => {
if !cleanup_result.cleaned_branches.is_empty() {
for branch in &cleanup_result.cleaned_branches {
Output::sub_item(format!(
"🗑️ Deleted: {}",
branch
));
}
}
}
Err(e) => {
Output::warning(format!(
"Branch cleanup failed: {}",
e
));
}
}
}
}
Err(e) => {
Output::warning(format!(
"Could not find cleanup candidates: {}",
e
));
}
}
}
let should_delete_stack = Confirm::with_theme(&ColorfulTheme::default())
.with_prompt(format!("Delete stack '{}'?", final_stack.name))
.default(true)
.interact()
.unwrap_or(false);
if should_delete_stack {
let mut delete_manager = StackManager::new(&repo_root)?;
match delete_manager.delete_stack(&stack_id) {
Ok(_) => {
Output::sub_item(format!("Stack '{}' deleted", final_stack.name));
}
Err(e) => {
Output::warning(format!("Could not delete stack: {}", e));
}
}
}
}
}
}
} else {
Output::error("No PRs were successfully landed");
}
Ok(())
}
async fn auto_land_stack(
force: bool,
dry_run: bool,
wait_for_builds: bool,
strategy: Option<MergeStrategyArg>,
build_timeout: u64,
) -> Result<()> {
land_stack(
None,
force,
dry_run,
true, wait_for_builds,
strategy,
build_timeout,
)
.await
}
async fn continue_land() -> Result<()> {
use crate::cli::output::Output;
Output::warning("'ca stack continue-land' is deprecated");
Output::tip("Use 'ca sync continue' to finish the rebase, then 'ca land' to continue merging");
println!();
let current_dir = env::current_dir()
.map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
let repo_root = find_repository_root(¤t_dir)
.map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
let git_repo = crate::git::GitRepository::open(&repo_root)?;
let git_dir = git_repo.git_dir();
let has_cherry_pick = git_dir.join("CHERRY_PICK_HEAD").exists();
let has_rebase = git_dir.join("REBASE_HEAD").exists()
|| git_dir.join("rebase-merge").exists()
|| git_dir.join("rebase-apply").exists();
if !has_cherry_pick && !has_rebase {
Output::info("No land operation in progress");
Output::tip("Use 'ca land' to start landing PRs");
return Ok(());
}
Output::section("Continuing land operation");
println!();
Output::info("Completing conflict resolution...");
let stack_manager = StackManager::new(&repo_root)?;
let options = crate::stack::RebaseOptions::default();
let rebase_manager = crate::stack::RebaseManager::new(stack_manager, git_repo, options);
match rebase_manager.continue_rebase() {
Ok(_) => {
Output::success("Conflict resolution completed");
}
Err(e) => {
Output::error("Failed to complete conflict resolution");
Output::tip("You may need to resolve conflicts first:");
Output::bullet("Edit conflicted files");
Output::bullet("Stage resolved files: git add <files>");
Output::bullet("Run 'ca land continue' again");
return Err(e);
}
}
println!();
let stack_manager = StackManager::new(&repo_root)?;
let active_stack = stack_manager.get_active_stack().ok_or_else(|| {
CascadeError::config("No active stack found. Cannot continue land operation.")
})?;
let stack_id = active_stack.id;
let base_branch = active_stack.base_branch.clone();
Output::info("Rebasing remaining stack entries...");
println!();
let git_repo_for_rebase = crate::git::GitRepository::open(&repo_root)?;
let mut rebase_manager = crate::stack::RebaseManager::new(
StackManager::new(&repo_root)?,
git_repo_for_rebase,
crate::stack::RebaseOptions {
strategy: crate::stack::RebaseStrategy::ForcePush,
target_base: Some(base_branch.clone()),
..Default::default()
},
);
let rebase_result = rebase_manager.rebase_stack(&stack_id)?;
if !rebase_result.success {
if !rebase_result.conflicts.is_empty() {
println!();
Output::error("Additional conflicts detected during rebase");
println!();
Output::tip("To resolve and continue:");
Output::bullet("Resolve conflicts in your editor");
Output::bullet("Stage resolved files: git add <files>");
Output::bullet("Finish the rebase: ca sync continue");
println!();
Output::tip("Or abort the rebase:");
Output::bullet("ca sync abort");
println!();
Output::tip("Once the rebase is complete, re-run 'ca land' to continue merging");
return Ok(());
}
Output::error("Failed to rebase remaining entries");
if let Some(error) = &rebase_result.error {
Output::sub_item(format!("Error: {}", error));
}
return Err(CascadeError::invalid_operation(
"Failed to rebase stack after conflict resolution",
));
}
println!();
Output::success(format!(
"Rebased {} remaining entries",
rebase_result.branch_mapping.len()
));
let config_dir = crate::config::get_repo_config_dir(&repo_root)?;
let config_path = config_dir.join("config.json");
if let Ok(settings) = crate::config::Settings::load_from_file(&config_path) {
if !rebase_result.branch_mapping.is_empty() {
println!();
Output::info("Updating pull requests...");
let cascade_config = crate::config::CascadeConfig {
bitbucket: Some(settings.bitbucket.clone()),
git: settings.git.clone(),
auth: crate::config::AuthConfig::default(),
cascade: settings.cascade.clone(),
};
let mut integration = crate::bitbucket::BitbucketIntegration::new(
StackManager::new(&repo_root)?,
cascade_config,
)?;
match integration
.update_prs_after_rebase(&stack_id, &rebase_result.branch_mapping)
.await
{
Ok(updated_prs) => {
if !updated_prs.is_empty() {
Output::success(format!("Updated {} pull requests", updated_prs.len()));
}
}
Err(e) => {
Output::warning(format!("Failed to update some PRs: {}", e));
Output::tip("PRs may need manual updates in Bitbucket");
}
}
}
}
println!();
Output::success("Land operation continued successfully");
println!();
Output::tip("Next steps:");
Output::bullet("Wait for builds to pass on rebased PRs");
Output::bullet("Once builds are green, run: ca land");
Ok(())
}
async fn abort_land() -> Result<()> {
Output::warning("'ca stack abort-land' is deprecated");
Output::tip("Use 'ca sync abort' to abort the rebase");
println!();
let current_dir = env::current_dir()
.map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
let repo_root = find_repository_root(¤t_dir)
.map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
let stack_manager = StackManager::new(&repo_root)?;
let git_repo = crate::git::GitRepository::open(&repo_root)?;
let options = crate::stack::RebaseOptions::default();
let rebase_manager = crate::stack::RebaseManager::new(stack_manager, git_repo, options);
if !rebase_manager.is_rebase_in_progress() {
Output::info(" No rebase in progress");
return Ok(());
}
println!("⚠️ Aborting rebase...");
match rebase_manager.abort_rebase() {
Ok(_) => {
Output::success("Rebase aborted successfully");
Output::tip("Run 'ca sync' to re-sync the stack, or 'ca land' when ready to merge");
}
Err(e) => {
warn!("❌ Failed to abort rebase: {}", e);
println!("⚠️ You may need to manually clean up the repository state");
}
}
Ok(())
}
async fn land_status() -> Result<()> {
let current_dir = env::current_dir()
.map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
let repo_root = find_repository_root(¤t_dir)
.map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
let stack_manager = StackManager::new(&repo_root)?;
let git_repo = crate::git::GitRepository::open(&repo_root)?;
println!("Land Status");
let git_dir = git_repo.git_dir();
let land_in_progress = git_dir.join("REBASE_HEAD").exists()
|| git_dir.join("rebase-merge").exists()
|| git_dir.join("rebase-apply").exists();
if land_in_progress {
println!(" Status: 🔄 Land operation in progress");
println!(
"
📝 Actions available:"
);
println!(" - 'ca sync continue' to finish the rebase");
println!(" - 'ca sync abort' to abort the rebase");
println!(" - 'ca land' to continue merging (after rebase is complete)");
println!(" - 'git status' to see conflicted files");
match git_repo.get_status() {
Ok(statuses) => {
let mut conflicts = Vec::new();
for status in statuses.iter() {
if status.status().contains(git2::Status::CONFLICTED) {
if let Some(path) = status.path() {
conflicts.push(path.to_string());
}
}
}
if !conflicts.is_empty() {
println!(" ⚠️ Conflicts in {} files:", conflicts.len());
for conflict in conflicts {
println!(" - {conflict}");
}
println!(
"
💡 To resolve conflicts:"
);
println!(" 1. Edit the conflicted files");
println!(" 2. Stage resolved files: git add <file>");
println!(" 3. Finish the rebase: ca sync continue");
println!(" 4. Then re-run: ca land");
}
}
Err(e) => {
warn!("Failed to get git status: {}", e);
}
}
} else {
println!(" Status: ✅ No land operation in progress");
if let Some(active_stack) = stack_manager.get_active_stack() {
println!(" Active stack: {}", active_stack.name);
println!(" Entries: {}", active_stack.entries.len());
println!(" Base branch: {}", active_stack.base_branch);
}
}
Ok(())
}
async fn repair_stack_data() -> Result<()> {
let current_dir = env::current_dir()
.map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
let repo_root = find_repository_root(¤t_dir)
.map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
let mut stack_manager = StackManager::new(&repo_root)?;
println!("🔧 Repairing stack data consistency...");
stack_manager.repair_all_stacks()?;
Output::success(" Stack data consistency repaired successfully!");
Output::tip(" Run 'ca stack --mergeable' to see updated status");
Ok(())
}
async fn cleanup_branches(
dry_run: bool,
force: bool,
include_stale: bool,
stale_days: u32,
cleanup_remote: bool,
include_non_stack: bool,
verbose: bool,
) -> Result<()> {
let current_dir = env::current_dir()
.map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
let repo_root = find_repository_root(¤t_dir)
.map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
let stack_manager = StackManager::new(&repo_root)?;
let git_repo = GitRepository::open(&repo_root)?;
let result = perform_cleanup(
&stack_manager,
&git_repo,
dry_run,
force,
include_stale,
stale_days,
cleanup_remote,
include_non_stack,
verbose,
)
.await?;
if result.total_candidates == 0 {
Output::success("No branches found that need cleanup");
return Ok(());
}
Output::section("Cleanup Results");
if dry_run {
Output::sub_item(format!(
"Found {} branches that would be cleaned up",
result.total_candidates
));
} else {
if !result.cleaned_branches.is_empty() {
Output::success(format!(
"Successfully cleaned up {} branches",
result.cleaned_branches.len()
));
for branch in &result.cleaned_branches {
Output::sub_item(format!("🗑️ Deleted: {branch}"));
}
}
if !result.skipped_branches.is_empty() {
Output::sub_item(format!(
"Skipped {} branches",
result.skipped_branches.len()
));
if verbose {
for (branch, reason) in &result.skipped_branches {
Output::sub_item(format!("⏭️ {branch}: {reason}"));
}
}
}
if !result.failed_branches.is_empty() {
Output::warning(format!(
"Failed to clean up {} branches",
result.failed_branches.len()
));
for (branch, error) in &result.failed_branches {
Output::sub_item(format!("❌ {branch}: {error}"));
}
}
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn perform_cleanup(
stack_manager: &StackManager,
git_repo: &GitRepository,
dry_run: bool,
force: bool,
include_stale: bool,
stale_days: u32,
cleanup_remote: bool,
include_non_stack: bool,
verbose: bool,
) -> Result<CleanupResult> {
let options = CleanupOptions {
dry_run,
force,
include_stale,
cleanup_remote,
stale_threshold_days: stale_days,
cleanup_non_stack: include_non_stack,
};
let stack_manager_copy = StackManager::new(stack_manager.repo_path())?;
let git_repo_copy = GitRepository::open(git_repo.path())?;
let mut cleanup_manager = CleanupManager::new(stack_manager_copy, git_repo_copy, options);
let candidates = cleanup_manager.find_cleanup_candidates()?;
if candidates.is_empty() {
return Ok(CleanupResult {
cleaned_branches: Vec::new(),
failed_branches: Vec::new(),
skipped_branches: Vec::new(),
total_candidates: 0,
});
}
if verbose || dry_run {
Output::section("Cleanup Candidates");
for candidate in &candidates {
let reason_icon = match candidate.reason {
crate::stack::CleanupReason::FullyMerged => "🔀",
crate::stack::CleanupReason::StackEntryMerged => "✅",
crate::stack::CleanupReason::Stale => "⏰",
crate::stack::CleanupReason::Orphaned => "👻",
};
Output::sub_item(format!(
"{} {} - {} ({})",
reason_icon,
candidate.branch_name,
candidate.reason_to_string(),
candidate.safety_info
));
}
}
if !force && !dry_run && !candidates.is_empty() {
Output::warning(format!("About to delete {} branches", candidates.len()));
let preview_count = 5.min(candidates.len());
for candidate in candidates.iter().take(preview_count) {
println!(" • {}", candidate.branch_name);
}
if candidates.len() > preview_count {
println!(" ... and {} more", candidates.len() - preview_count);
}
println!();
let should_continue = Confirm::with_theme(&ColorfulTheme::default())
.with_prompt("Continue with branch cleanup?")
.default(false)
.interact()
.map_err(|e| CascadeError::config(format!("Failed to get user confirmation: {e}")))?;
if !should_continue {
Output::sub_item("Cleanup cancelled");
return Ok(CleanupResult {
cleaned_branches: Vec::new(),
failed_branches: Vec::new(),
skipped_branches: Vec::new(),
total_candidates: candidates.len(),
});
}
}
cleanup_manager.perform_cleanup(&candidates)
}
async fn perform_simple_cleanup(
stack_manager: &StackManager,
git_repo: &GitRepository,
dry_run: bool,
) -> Result<CleanupResult> {
perform_cleanup(
stack_manager,
git_repo,
dry_run,
false, false, 30, false, false, false, )
.await
}
async fn analyze_commits_for_safeguards(
commits_to_push: &[String],
repo: &GitRepository,
dry_run: bool,
) -> Result<()> {
const LARGE_COMMIT_THRESHOLD: usize = 10;
const WEEK_IN_SECONDS: i64 = 7 * 24 * 3600;
if commits_to_push.len() > LARGE_COMMIT_THRESHOLD {
println!(
"⚠️ Warning: About to push {} commits to stack",
commits_to_push.len()
);
println!(" This may indicate a merge commit issue or unexpected commit range.");
println!(" Large commit counts often result from merging instead of rebasing.");
if !dry_run && !confirm_large_push(commits_to_push.len())? {
return Err(CascadeError::config("Push cancelled by user"));
}
}
let commit_objects: Result<Vec<_>> = commits_to_push
.iter()
.map(|hash| repo.get_commit(hash))
.collect();
let commit_objects = commit_objects?;
let merge_commits: Vec<_> = commit_objects
.iter()
.filter(|c| c.parent_count() > 1)
.collect();
if !merge_commits.is_empty() {
println!(
"⚠️ Warning: {} merge commits detected in push",
merge_commits.len()
);
println!(" This often indicates you merged instead of rebased.");
println!(" Consider using 'ca sync' to rebase on the base branch.");
println!(" Merge commits in stacks can cause confusion and duplicate work.");
}
if commit_objects.len() > 1 {
let oldest_commit_time = commit_objects.first().unwrap().time().seconds();
let newest_commit_time = commit_objects.last().unwrap().time().seconds();
let time_span = newest_commit_time - oldest_commit_time;
if time_span > WEEK_IN_SECONDS {
let days = time_span / (24 * 3600);
println!("⚠️ Warning: Commits span {days} days");
println!(" This may indicate merged history rather than new work.");
println!(" Recent work should typically span hours or days, not weeks.");
}
}
if commits_to_push.len() > 5 {
Output::tip(" Tip: If you only want recent commits, use:");
println!(
" ca push --since HEAD~{} # pushes last {} commits",
std::cmp::min(commits_to_push.len(), 5),
std::cmp::min(commits_to_push.len(), 5)
);
println!(" ca push --commits <hash1>,<hash2> # pushes specific commits");
println!(" ca push --dry-run # preview what would be pushed");
}
if dry_run {
println!("🔍 DRY RUN: Would push {} commits:", commits_to_push.len());
for (i, (commit_hash, commit_obj)) in commits_to_push
.iter()
.zip(commit_objects.iter())
.enumerate()
{
let summary = commit_obj.summary().unwrap_or("(no message)");
let short_hash = &commit_hash[..std::cmp::min(commit_hash.len(), 7)];
println!(" {}: {} ({})", i + 1, summary, short_hash);
}
Output::tip(" Run without --dry-run to actually push these commits.");
}
Ok(())
}
fn confirm_large_push(count: usize) -> Result<bool> {
let should_continue = Confirm::with_theme(&ColorfulTheme::default())
.with_prompt(format!("Continue pushing {count} commits?"))
.default(false)
.interact()
.map_err(|e| CascadeError::config(format!("Failed to get user confirmation: {e}")))?;
Ok(should_continue)
}
fn parse_entry_spec(spec: &str, max_entries: usize) -> Result<Vec<usize>> {
let mut indices: Vec<usize> = Vec::new();
if spec.contains('-') && !spec.contains(',') {
let parts: Vec<&str> = spec.split('-').collect();
if parts.len() != 2 {
return Err(CascadeError::config(
"Invalid range format. Use 'start-end' (e.g., '1-5')",
));
}
let start: usize = parts[0]
.trim()
.parse()
.map_err(|_| CascadeError::config("Invalid start number in range"))?;
let end: usize = parts[1]
.trim()
.parse()
.map_err(|_| CascadeError::config("Invalid end number in range"))?;
if start == 0 || end == 0 {
return Err(CascadeError::config("Entry numbers are 1-based"));
}
if start > max_entries || end > max_entries {
return Err(CascadeError::config(format!(
"Entry number out of bounds. Stack has {max_entries} entries"
)));
}
let (lo, hi) = if start <= end {
(start, end)
} else {
(end, start)
};
for i in lo..=hi {
indices.push(i);
}
} else if spec.contains(',') {
for part in spec.split(',') {
let num: usize = part.trim().parse().map_err(|_| {
CascadeError::config(format!("Invalid entry number: {}", part.trim()))
})?;
if num == 0 {
return Err(CascadeError::config("Entry numbers are 1-based"));
}
if num > max_entries {
return Err(CascadeError::config(format!(
"Entry {num} out of bounds. Stack has {max_entries} entries"
)));
}
indices.push(num);
}
} else {
let num: usize = spec
.trim()
.parse()
.map_err(|_| CascadeError::config(format!("Invalid entry number: {spec}")))?;
if num == 0 {
return Err(CascadeError::config("Entry numbers are 1-based"));
}
if num > max_entries {
return Err(CascadeError::config(format!(
"Entry {num} out of bounds. Stack has {max_entries} entries"
)));
}
indices.push(num);
}
indices.sort();
indices.dedup();
Ok(indices)
}
async fn drop_entries(
entry_spec: String,
keep_branch: bool,
keep_pr: bool,
force: bool,
yes: bool,
) -> Result<()> {
let current_dir = env::current_dir()
.map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
let repo_root = find_repository_root(¤t_dir)
.map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
let mut manager = StackManager::new(&repo_root)?;
let repo = GitRepository::open(&repo_root)?;
let active_stack = manager.get_active_stack().ok_or_else(|| {
CascadeError::config("No active stack. Create a stack first with 'ca stacks create'")
})?;
let stack_id = active_stack.id;
let entry_count = active_stack.entries.len();
if entry_count == 0 {
Output::info("Stack is empty, nothing to drop.");
return Ok(());
}
let indices = parse_entry_spec(&entry_spec, entry_count)?;
for &idx in &indices {
let entry = &active_stack.entries[idx - 1];
if entry.is_merged {
return Err(CascadeError::config(format!(
"Entry {} ('{}') is already merged. Use 'ca stacks cleanup' to remove merged entries.",
idx,
entry.short_message(40)
)));
}
}
let has_submitted = indices
.iter()
.any(|&idx| active_stack.entries[idx - 1].is_submitted);
Output::section(format!("Entries to drop ({})", indices.len()));
for &idx in &indices {
let entry = &active_stack.entries[idx - 1];
let pr_status = if entry.is_submitted {
format!(" [PR #{}]", entry.pull_request_id.as_deref().unwrap_or("?"))
} else {
String::new()
};
Output::numbered_item(
idx,
format!(
"{} {} (branch: {}){}",
entry.short_hash(),
entry.short_message(40),
entry.branch,
pr_status
),
);
}
if !force && !yes {
if has_submitted {
Output::warning("Some entries have associated pull requests.");
}
let default_confirm = !has_submitted;
let should_continue = Confirm::with_theme(&ColorfulTheme::default())
.with_prompt(format!("Drop {} entry/entries from stack?", indices.len()))
.default(default_confirm)
.interact()
.map_err(|e| CascadeError::config(format!("Failed to get user confirmation: {e}")))?;
if !should_continue {
Output::info("Drop cancelled.");
return Ok(());
}
}
let entries_info: Vec<(String, Option<String>, bool)> = indices
.iter()
.map(|&idx| {
let entry = &active_stack.entries[idx - 1];
(
entry.branch.clone(),
entry.pull_request_id.clone(),
entry.is_submitted,
)
})
.collect();
let current_branch = repo.get_current_branch()?;
let mut pr_manager = None;
for (i, &idx) in indices.iter().enumerate().rev() {
let zero_idx = idx - 1;
match manager.remove_stack_entry_at(&stack_id, zero_idx)? {
Some(removed) => {
Output::success(format!(
"Dropped entry {}: {} {}",
idx,
removed.short_hash(),
removed.short_message(40)
));
}
None => {
Output::warning(format!("Could not remove entry {idx}"));
continue;
}
}
let (ref branch_name, ref pr_id, is_submitted) = entries_info[i];
if !keep_branch && *branch_name != current_branch {
match repo.delete_branch(branch_name) {
Ok(_) => Output::sub_item(format!("Deleted branch: {branch_name}")),
Err(e) => Output::warning(format!("Could not delete branch {branch_name}: {e}")),
}
}
if is_submitted && !keep_pr {
if let Some(pr_id_str) = pr_id {
if let Ok(pr_id_num) = pr_id_str.parse::<u64>() {
let should_decline = if force {
true
} else {
Confirm::with_theme(&ColorfulTheme::default())
.with_prompt(format!("Decline PR #{pr_id_num} on Bitbucket?"))
.default(true)
.interact()
.unwrap_or(false)
};
if should_decline {
if pr_manager.is_none() {
let config_dir = crate::config::get_repo_config_dir(&repo_root)?;
let config_path = config_dir.join("config.json");
let settings = crate::config::Settings::load_from_file(&config_path)?;
let client =
crate::bitbucket::BitbucketClient::new(&settings.bitbucket)?;
pr_manager = Some(crate::bitbucket::PullRequestManager::new(client));
}
if let Some(ref mgr) = pr_manager {
match mgr
.decline_pull_request(pr_id_num, "Dropped from stack")
.await
{
Ok(_) => Output::sub_item(format!(
"Declined PR #{pr_id_num} on Bitbucket"
)),
Err(e) => Output::warning(format!(
"Failed to decline PR #{pr_id_num}: {e}"
)),
}
}
}
}
}
}
}
Output::success(format!(
"Dropped {} entry/entries from stack",
indices.len()
));
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command;
use tempfile::TempDir;
fn create_test_repo() -> Result<(TempDir, std::path::PathBuf)> {
let temp_dir = TempDir::new()
.map_err(|e| CascadeError::config(format!("Failed to create temp directory: {e}")))?;
let repo_path = temp_dir.path().to_path_buf();
let output = Command::new("git")
.args(["init"])
.current_dir(&repo_path)
.output()
.map_err(|e| CascadeError::config(format!("Failed to run git init: {e}")))?;
if !output.status.success() {
return Err(CascadeError::config("Git init failed".to_string()));
}
let output = Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(&repo_path)
.output()
.map_err(|e| CascadeError::config(format!("Failed to run git config: {e}")))?;
if !output.status.success() {
return Err(CascadeError::config(
"Git config user.name failed".to_string(),
));
}
let output = Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(&repo_path)
.output()
.map_err(|e| CascadeError::config(format!("Failed to run git config: {e}")))?;
if !output.status.success() {
return Err(CascadeError::config(
"Git config user.email failed".to_string(),
));
}
std::fs::write(repo_path.join("README.md"), "# Test")
.map_err(|e| CascadeError::config(format!("Failed to write file: {e}")))?;
let output = Command::new("git")
.args(["add", "."])
.current_dir(&repo_path)
.output()
.map_err(|e| CascadeError::config(format!("Failed to run git add: {e}")))?;
if !output.status.success() {
return Err(CascadeError::config("Git add failed".to_string()));
}
let output = Command::new("git")
.args(["commit", "-m", "Initial commit"])
.current_dir(&repo_path)
.output()
.map_err(|e| CascadeError::config(format!("Failed to run git commit: {e}")))?;
if !output.status.success() {
return Err(CascadeError::config("Git commit failed".to_string()));
}
crate::config::initialize_repo(&repo_path, Some("https://test.bitbucket.com".to_string()))?;
Ok((temp_dir, repo_path))
}
#[tokio::test]
async fn test_create_stack() {
let (temp_dir, repo_path) = match create_test_repo() {
Ok(repo) => repo,
Err(_) => {
println!("Skipping test due to git environment setup failure");
return;
}
};
let _ = &temp_dir;
let original_dir = env::current_dir().map_err(|_| "Failed to get current dir");
match env::set_current_dir(&repo_path) {
Ok(_) => {
let result = create_stack(
"test-stack".to_string(),
None, Some("Test description".to_string()),
)
.await;
if let Ok(orig) = original_dir {
let _ = env::set_current_dir(orig);
}
assert!(
result.is_ok(),
"Stack creation should succeed in initialized repository"
);
}
Err(_) => {
println!("Skipping test due to directory access restrictions");
}
}
}
#[tokio::test]
async fn test_list_empty_stacks() {
let (temp_dir, repo_path) = match create_test_repo() {
Ok(repo) => repo,
Err(_) => {
println!("Skipping test due to git environment setup failure");
return;
}
};
let _ = &temp_dir;
let original_dir = env::current_dir().map_err(|_| "Failed to get current dir");
match env::set_current_dir(&repo_path) {
Ok(_) => {
let result = list_stacks(false, false, None).await;
if let Ok(orig) = original_dir {
let _ = env::set_current_dir(orig);
}
assert!(
result.is_ok(),
"Listing stacks should succeed in initialized repository"
);
}
Err(_) => {
println!("Skipping test due to directory access restrictions");
}
}
}
#[test]
fn test_extract_feature_from_wip_basic() {
let messages = vec![
"WIP: add authentication".to_string(),
"WIP: implement login flow".to_string(),
];
let result = extract_feature_from_wip(&messages);
assert_eq!(result, "Add authentication");
}
#[test]
fn test_extract_feature_from_wip_capitalize() {
let messages = vec!["WIP: fix user validation bug".to_string()];
let result = extract_feature_from_wip(&messages);
assert_eq!(result, "Fix user validation bug");
}
#[test]
fn test_extract_feature_from_wip_fallback() {
let messages = vec![
"WIP user interface changes".to_string(),
"wip: css styling".to_string(),
];
let result = extract_feature_from_wip(&messages);
assert!(result.contains("Implement") || result.contains("Squashed") || result.len() > 5);
}
#[test]
fn test_extract_feature_from_wip_empty() {
let messages = vec![];
let result = extract_feature_from_wip(&messages);
assert_eq!(result, "Squashed 0 commits");
}
#[test]
fn test_extract_feature_from_wip_short_message() {
let messages = vec!["WIP: x".to_string()];
let result = extract_feature_from_wip(&messages);
assert!(result.starts_with("Implement") || result.contains("Squashed"));
}
#[test]
fn test_squash_message_final_strategy() {
let messages = [
"Final: implement user authentication system".to_string(),
"WIP: add tests".to_string(),
"WIP: fix validation".to_string(),
];
assert!(messages[0].starts_with("Final:"));
let extracted = messages[0].trim_start_matches("Final:").trim();
assert_eq!(extracted, "implement user authentication system");
}
#[test]
fn test_squash_message_wip_detection() {
let messages = [
"WIP: start feature".to_string(),
"WIP: continue work".to_string(),
"WIP: almost done".to_string(),
"Regular commit message".to_string(),
];
let wip_count = messages
.iter()
.filter(|m| {
m.to_lowercase().starts_with("wip") || m.to_lowercase().contains("work in progress")
})
.count();
assert_eq!(wip_count, 3); assert!(wip_count > messages.len() / 2);
let non_wip: Vec<&String> = messages
.iter()
.filter(|m| {
!m.to_lowercase().starts_with("wip")
&& !m.to_lowercase().contains("work in progress")
})
.collect();
assert_eq!(non_wip.len(), 1);
assert_eq!(non_wip[0], "Regular commit message");
}
#[test]
fn test_squash_message_all_wip() {
let messages = vec![
"WIP: add feature A".to_string(),
"WIP: add feature B".to_string(),
"WIP: finish implementation".to_string(),
];
let result = extract_feature_from_wip(&messages);
assert_eq!(result, "Add feature A");
}
#[test]
fn test_squash_message_edge_cases() {
let empty_messages: Vec<String> = vec![];
let result = extract_feature_from_wip(&empty_messages);
assert_eq!(result, "Squashed 0 commits");
let whitespace_messages = vec![" ".to_string(), "\t\n".to_string()];
let result = extract_feature_from_wip(&whitespace_messages);
assert!(result.contains("Squashed") || result.contains("Implement"));
let mixed_case = vec!["wip: Add Feature".to_string()];
let result = extract_feature_from_wip(&mixed_case);
assert_eq!(result, "Add Feature");
}
#[tokio::test]
async fn test_auto_land_wrapper() {
let (temp_dir, repo_path) = match create_test_repo() {
Ok(repo) => repo,
Err(_) => {
println!("Skipping test due to git environment setup failure");
return;
}
};
let _ = &temp_dir;
crate::config::initialize_repo(&repo_path, Some("https://test.bitbucket.com".to_string()))
.expect("Failed to initialize Cascade in test repo");
let original_dir = env::current_dir().map_err(|_| "Failed to get current dir");
match env::set_current_dir(&repo_path) {
Ok(_) => {
let result = create_stack(
"test-stack".to_string(),
None,
Some("Test stack for auto-land".to_string()),
)
.await;
if let Ok(orig) = original_dir {
let _ = env::set_current_dir(orig);
}
assert!(
result.is_ok(),
"Stack creation should succeed in initialized repository"
);
}
Err(_) => {
println!("Skipping test due to directory access restrictions");
}
}
}
#[test]
fn test_auto_land_action_enum() {
use crate::cli::commands::stack::StackAction;
let _action = StackAction::AutoLand {
force: false,
dry_run: true,
wait_for_builds: true,
strategy: Some(MergeStrategyArg::Squash),
build_timeout: 1800,
};
}
#[test]
fn test_merge_strategy_conversion() {
let squash_strategy = MergeStrategyArg::Squash;
let merge_strategy: crate::bitbucket::pull_request::MergeStrategy = squash_strategy.into();
match merge_strategy {
crate::bitbucket::pull_request::MergeStrategy::Squash => {
}
_ => unreachable!("SquashStrategyArg only has Squash variant"),
}
let merge_strategy = MergeStrategyArg::Merge;
let converted: crate::bitbucket::pull_request::MergeStrategy = merge_strategy.into();
match converted {
crate::bitbucket::pull_request::MergeStrategy::Merge => {
}
_ => unreachable!("MergeStrategyArg::Merge maps to MergeStrategy::Merge"),
}
}
#[test]
fn test_auto_merge_conditions_structure() {
use std::time::Duration;
let conditions = crate::bitbucket::pull_request::AutoMergeConditions {
merge_strategy: crate::bitbucket::pull_request::MergeStrategy::Squash,
wait_for_builds: true,
build_timeout: Duration::from_secs(1800),
allowed_authors: None,
};
assert!(conditions.wait_for_builds);
assert_eq!(conditions.build_timeout.as_secs(), 1800);
assert!(conditions.allowed_authors.is_none());
assert!(matches!(
conditions.merge_strategy,
crate::bitbucket::pull_request::MergeStrategy::Squash
));
}
#[test]
fn test_polling_constants() {
use std::time::Duration;
let expected_polling_interval = Duration::from_secs(30);
assert!(expected_polling_interval.as_secs() >= 10); assert!(expected_polling_interval.as_secs() <= 60); assert_eq!(expected_polling_interval.as_secs(), 30); }
#[test]
fn test_build_timeout_defaults() {
const DEFAULT_TIMEOUT: u64 = 1800; assert_eq!(DEFAULT_TIMEOUT, 1800);
let timeout_value = 1800u64;
assert!(timeout_value >= 300); assert!(timeout_value <= 3600); }
#[test]
fn test_scattered_commit_detection() {
use std::collections::HashSet;
let mut source_branches = HashSet::new();
source_branches.insert("feature-branch-1".to_string());
source_branches.insert("feature-branch-2".to_string());
source_branches.insert("feature-branch-3".to_string());
let single_branch = HashSet::from(["main".to_string()]);
assert_eq!(single_branch.len(), 1);
assert!(source_branches.len() > 1);
assert_eq!(source_branches.len(), 3);
assert!(source_branches.contains("feature-branch-1"));
assert!(source_branches.contains("feature-branch-2"));
assert!(source_branches.contains("feature-branch-3"));
}
#[test]
fn test_source_branch_tracking() {
let branch_a = "feature-work";
let branch_b = "feature-work";
assert_eq!(branch_a, branch_b);
let branch_1 = "feature-ui";
let branch_2 = "feature-api";
assert_ne!(branch_1, branch_2);
assert!(branch_1.starts_with("feature-"));
assert!(branch_2.starts_with("feature-"));
}
#[tokio::test]
async fn test_push_default_behavior() {
let (temp_dir, repo_path) = match create_test_repo() {
Ok(repo) => repo,
Err(_) => {
println!("Skipping test due to git environment setup failure");
return;
}
};
let _ = &temp_dir;
if !repo_path.exists() {
println!("Skipping test due to temporary directory creation issue");
return;
}
let original_dir = env::current_dir().map_err(|_| "Failed to get current dir");
match env::set_current_dir(&repo_path) {
Ok(_) => {
let result = push_to_stack(
None, None, None, None, None, None, None, false, false, false, true, )
.await;
if let Ok(orig) = original_dir {
let _ = env::set_current_dir(orig);
}
match &result {
Err(e) => {
let error_msg = e.to_string();
assert!(
error_msg.contains("No active stack")
|| error_msg.contains("config")
|| error_msg.contains("current directory")
|| error_msg.contains("Not a git repository")
|| error_msg.contains("could not find repository"),
"Expected 'No active stack' or repository error, got: {error_msg}"
);
}
Ok(_) => {
println!(
"Push succeeded unexpectedly - test environment may have active stack"
);
}
}
}
Err(_) => {
println!("Skipping test due to directory access restrictions");
}
}
let push_action = StackAction::Push {
branch: None,
message: None,
commit: None,
since: None,
commits: None,
squash: None,
squash_since: None,
auto_branch: false,
allow_base_branch: false,
dry_run: false,
yes: false,
};
assert!(matches!(
push_action,
StackAction::Push {
branch: None,
message: None,
commit: None,
since: None,
commits: None,
squash: None,
squash_since: None,
auto_branch: false,
allow_base_branch: false,
dry_run: false,
yes: false
}
));
}
#[tokio::test]
async fn test_submit_default_behavior() {
let (temp_dir, repo_path) = match create_test_repo() {
Ok(repo) => repo,
Err(_) => {
println!("Skipping test due to git environment setup failure");
return;
}
};
let _ = &temp_dir;
if !repo_path.exists() {
println!("Skipping test due to temporary directory creation issue");
return;
}
let original_dir = match env::current_dir() {
Ok(dir) => dir,
Err(_) => {
println!("Skipping test due to current directory access restrictions");
return;
}
};
match env::set_current_dir(&repo_path) {
Ok(_) => {
let result = submit_entry(
None, None, None, None, false, true, )
.await;
let _ = env::set_current_dir(original_dir);
match &result {
Err(e) => {
let error_msg = e.to_string();
assert!(
error_msg.contains("No active stack")
|| error_msg.contains("config")
|| error_msg.contains("current directory")
|| error_msg.contains("Not a git repository")
|| error_msg.contains("could not find repository"),
"Expected 'No active stack' or repository error, got: {error_msg}"
);
}
Ok(_) => {
println!("Submit succeeded unexpectedly - test environment may have active stack");
}
}
}
Err(_) => {
println!("Skipping test due to directory access restrictions");
}
}
let submit_action = StackAction::Submit {
entry: None,
title: None,
description: None,
range: None,
draft: true, open: true,
};
assert!(matches!(
submit_action,
StackAction::Submit {
entry: None,
title: None,
description: None,
range: None,
draft: true, open: true
}
));
}
#[test]
fn test_targeting_options_still_work() {
let commits = "abc123,def456,ghi789";
let parsed: Vec<&str> = commits.split(',').map(|s| s.trim()).collect();
assert_eq!(parsed.len(), 3);
assert_eq!(parsed[0], "abc123");
assert_eq!(parsed[1], "def456");
assert_eq!(parsed[2], "ghi789");
let range = "1-3";
assert!(range.contains('-'));
let parts: Vec<&str> = range.split('-').collect();
assert_eq!(parts.len(), 2);
let since_ref = "HEAD~3";
assert!(since_ref.starts_with("HEAD"));
assert!(since_ref.contains('~'));
}
#[test]
fn test_command_flow_logic() {
assert!(matches!(
StackAction::Push {
branch: None,
message: None,
commit: None,
since: None,
commits: None,
squash: None,
squash_since: None,
auto_branch: false,
allow_base_branch: false,
dry_run: false,
yes: false
},
StackAction::Push { .. }
));
assert!(matches!(
StackAction::Submit {
entry: None,
title: None,
description: None,
range: None,
draft: false,
open: true
},
StackAction::Submit { .. }
));
}
#[tokio::test]
async fn test_deactivate_command_structure() {
let deactivate_action = StackAction::Deactivate { force: false };
assert!(matches!(
deactivate_action,
StackAction::Deactivate { force: false }
));
}
}