use super::metadata::RepositoryMetadata;
use super::{CommitMetadata, Stack, StackEntry, StackMetadata, StackStatus};
use crate::cli::output::Output;
use crate::config::{get_repo_config_dir, Settings};
use crate::errors::{CascadeError, Result};
use crate::git::GitRepository;
use chrono::Utc;
use dialoguer::{theme::ColorfulTheme, Select};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use tracing::{debug, warn};
use uuid::Uuid;
#[derive(Debug)]
pub enum BranchModification {
Missing {
branch: String,
entry_id: Uuid,
expected_commit: String,
},
ExtraCommits {
branch: String,
entry_id: Uuid,
expected_commit: String,
actual_commit: String,
extra_commit_count: usize,
extra_commit_messages: Vec<String>,
},
}
pub struct StackManager {
repo: GitRepository,
repo_path: PathBuf,
config_dir: PathBuf,
stacks_file: PathBuf,
metadata_file: PathBuf,
stacks: HashMap<Uuid, Stack>,
metadata: RepositoryMetadata,
}
impl StackManager {
pub fn new(repo_path: &Path) -> Result<Self> {
let repo = GitRepository::open(repo_path)?;
let config_dir = get_repo_config_dir(repo_path)?;
let stacks_file = config_dir.join("stacks.json");
let metadata_file = config_dir.join("metadata.json");
let config_file = config_dir.join("config.json");
let settings = Settings::load_from_file(&config_file).unwrap_or_default();
let configured_default = &settings.git.default_branch;
let default_base = if repo.branch_exists(configured_default) {
configured_default.clone()
} else {
match repo.detect_main_branch() {
Ok(detected) => {
detected
}
Err(_) => {
configured_default.clone()
}
}
};
let mut manager = Self {
repo,
repo_path: repo_path.to_path_buf(),
config_dir,
stacks_file,
metadata_file,
stacks: HashMap::new(),
metadata: RepositoryMetadata::new(default_base),
};
manager.load_from_disk()?;
Ok(manager)
}
pub fn create_stack(
&mut self,
name: String,
base_branch: Option<String>,
description: Option<String>,
) -> Result<Uuid> {
if self.metadata.find_stack_by_name(&name).is_some() {
return Err(CascadeError::config(format!(
"Stack '{name}' already exists"
)));
}
let base_branch = base_branch.unwrap_or_else(|| {
if let Ok(Some(detected_parent)) = self.repo.detect_parent_branch() {
detected_parent
} else {
self.metadata.default_base_branch.clone()
}
});
if !self.repo.branch_exists_or_fetch(&base_branch)? {
return Err(CascadeError::branch(format!(
"Base branch '{base_branch}' does not exist locally or remotely"
)));
}
let current_branch = self.repo.get_current_branch().ok();
let mut stack = Stack::new(name.clone(), base_branch.clone(), description.clone());
if let Some(ref branch) = current_branch {
if branch != &base_branch {
stack.working_branch = Some(branch.clone());
}
}
let stack_id = stack.id;
let stack_metadata = StackMetadata::new(stack_id, name, base_branch, description);
self.stacks.insert(stack_id, stack);
self.metadata.add_stack(stack_metadata);
self.save_to_disk()?;
Ok(stack_id)
}
pub fn get_stack(&self, stack_id: &Uuid) -> Option<&Stack> {
self.stacks.get(stack_id)
}
pub fn get_stack_mut(&mut self, stack_id: &Uuid) -> Option<&mut Stack> {
self.stacks.get_mut(stack_id)
}
pub fn get_stack_by_name(&self, name: &str) -> Option<&Stack> {
if let Some(metadata) = self.metadata.find_stack_by_name(name) {
self.stacks.get(&metadata.stack_id)
} else {
None
}
}
pub fn get_stack_by_name_mut(&mut self, name: &str) -> Option<&mut Stack> {
if let Some(metadata) = self.metadata.find_stack_by_name(name) {
self.stacks.get_mut(&metadata.stack_id)
} else {
None
}
}
pub fn update_stack_working_branch(&mut self, name: &str, branch: String) -> Result<()> {
if let Some(stack) = self.get_stack_by_name_mut(name) {
stack.working_branch = Some(branch);
self.save_to_disk()?;
Ok(())
} else {
Err(CascadeError::config(format!("Stack '{name}' not found")))
}
}
fn find_stack_id_for_branch(&self, branch: &str) -> Option<Uuid> {
for stack in self.stacks.values() {
if stack.working_branch.as_deref() == Some(branch) {
return Some(stack.id);
}
}
for stack in self.stacks.values() {
for entry in &stack.entries {
if entry.branch == branch {
return Some(stack.id);
}
}
}
None
}
fn get_active_stack_id(&self) -> Option<Uuid> {
let current_branch = self.repo.get_current_branch().ok()?;
self.find_stack_id_for_branch(¤t_branch)
}
pub fn get_active_stack(&self) -> Option<&Stack> {
let stack_id = self.get_active_stack_id()?;
self.stacks.get(&stack_id)
}
pub fn get_active_stack_mut(&mut self) -> Option<&mut Stack> {
let stack_id = self.get_active_stack_id()?;
self.stacks.get_mut(&stack_id)
}
pub fn checkout_stack_branch(&self, stack_id: &Uuid) -> Result<()> {
let stack = self
.stacks
.get(stack_id)
.ok_or_else(|| CascadeError::config(format!("Stack with ID {stack_id} not found")))?;
let target_branch = stack
.working_branch
.as_deref()
.or_else(|| stack.entries.last().map(|e| e.branch.as_str()))
.ok_or_else(|| {
CascadeError::config(format!(
"Stack '{}' has no working branch or entries",
stack.name
))
})?
.to_string();
self.repo.checkout_branch(&target_branch)?;
Ok(())
}
pub fn delete_stack(&mut self, stack_id: &Uuid) -> Result<Stack> {
let stack = self
.stacks
.remove(stack_id)
.ok_or_else(|| CascadeError::config(format!("Stack with ID {stack_id} not found")))?;
self.metadata.remove_stack(stack_id);
let stack_commits: Vec<String> = self
.metadata
.commits
.values()
.filter(|commit| &commit.stack_id == stack_id)
.map(|commit| commit.hash.clone())
.collect();
for commit_hash in stack_commits {
self.metadata.remove_commit(&commit_hash);
}
self.save_to_disk()?;
Ok(stack)
}
pub fn push_to_stack(
&mut self,
branch: String,
commit_hash: String,
message: String,
source_branch: String,
) -> Result<Uuid> {
let stack_id = self.get_active_stack_id().ok_or_else(|| {
CascadeError::config("No active stack (current branch doesn't belong to any stack)")
})?;
let mut reconciled = false;
{
let stack = self
.stacks
.get_mut(&stack_id)
.ok_or_else(|| CascadeError::config("Active stack not found"))?;
if !stack.entries.is_empty() {
let mut updates = Vec::new();
for entry in &stack.entries {
if let Ok(current_commit) = self.repo.get_branch_head(&entry.branch) {
if entry.commit_hash != current_commit {
debug!(
"Reconciling stale metadata for '{}': updating hash from {} to {} (current branch HEAD)",
entry.branch,
&entry.commit_hash[..8],
¤t_commit[..8]
);
updates.push((entry.id, current_commit));
}
}
}
for (entry_id, new_hash) in updates {
stack
.update_entry_commit_hash(&entry_id, new_hash)
.map_err(CascadeError::config)?;
reconciled = true;
}
}
}
if reconciled {
debug!("Saving reconciled metadata before validation");
self.save_to_disk()?;
}
let stack = self
.stacks
.get_mut(&stack_id)
.ok_or_else(|| CascadeError::config("Active stack not found"))?;
if !stack.entries.is_empty() {
if let Err(integrity_error) = stack.validate_git_integrity(&self.repo) {
return Err(CascadeError::validation(format!(
"Git integrity validation failed:\n{}\n\n\
Fix the stack integrity issues first using 'ca stack validate {}' for details.",
integrity_error, stack.name
)));
}
}
if !self.repo.commit_exists(&commit_hash)? {
return Err(CascadeError::branch(format!(
"Commit {commit_hash} does not exist"
)));
}
let message_trimmed = message.trim();
if let Some(duplicate_entry) = stack
.entries
.iter()
.find(|entry| entry.message.trim() == message_trimmed)
{
return Err(CascadeError::validation(format!(
"Duplicate commit message in stack: \"{}\"\n\n\
This message already exists in entry {} (commit: {})\n\n\
💡 Consider using a more specific message:\n\
• Add context: \"{} - add validation\"\n\
• Be more specific: \"Fix user authentication timeout bug\"\n\
• Or amend the previous commit: git commit --amend",
message_trimmed,
duplicate_entry.id,
&duplicate_entry.commit_hash[..8],
message_trimmed
)));
}
if stack.entries.is_empty() {
let current_branch = self.repo.get_current_branch()?;
if stack.working_branch.is_none() && current_branch != stack.base_branch {
stack.working_branch = Some(current_branch.clone());
tracing::debug!(
"Set working branch for stack '{}' to '{}'",
stack.name,
current_branch
);
}
if current_branch != stack.base_branch && current_branch != "HEAD" {
let base_exists = self.repo.branch_exists(&stack.base_branch);
let current_is_feature = current_branch.starts_with("feature/")
|| current_branch.starts_with("fix/")
|| current_branch.starts_with("chore/")
|| current_branch.contains("feature")
|| current_branch.contains("fix");
if base_exists && current_is_feature {
tracing::debug!(
"First commit detected: updating stack '{}' base branch from '{}' to '{}'",
stack.name,
stack.base_branch,
current_branch
);
Output::info("Smart Base Branch Update:");
Output::sub_item(format!(
"Stack '{}' was created with base '{}'",
stack.name, stack.base_branch
));
Output::sub_item(format!(
"You're now working on feature branch '{current_branch}'"
));
Output::sub_item("Updating stack base branch to match your workflow");
stack.base_branch = current_branch.clone();
if let Some(stack_meta) = self.metadata.get_stack_mut(&stack_id) {
stack_meta.base_branch = current_branch.clone();
stack_meta.set_current_branch(Some(current_branch.clone()));
}
println!(
" ✅ Stack '{}' base branch updated to '{current_branch}'",
stack.name
);
}
}
}
if self.repo.branch_exists(&branch) {
self.repo
.update_branch_to_commit(&branch, &commit_hash)
.map_err(|e| {
CascadeError::branch(format!(
"Failed to update existing branch '{}' to commit {}: {}",
branch,
&commit_hash[..8],
e
))
})?;
} else {
self.repo
.create_branch(&branch, Some(&commit_hash))
.map_err(|e| {
CascadeError::branch(format!(
"Failed to create branch '{}' from commit {}: {}",
branch,
&commit_hash[..8],
e
))
})?;
}
let entry_id = stack.push_entry(branch.clone(), commit_hash.clone(), message.clone());
let commit_metadata = CommitMetadata::new(
commit_hash.clone(),
message,
entry_id,
stack_id,
branch.clone(),
source_branch,
);
self.metadata.add_commit(commit_metadata);
if let Some(stack_meta) = self.metadata.get_stack_mut(&stack_id) {
stack_meta.add_branch(branch);
stack_meta.add_commit(commit_hash);
}
self.save_to_disk()?;
Ok(entry_id)
}
pub fn pop_from_stack(&mut self) -> Result<StackEntry> {
let stack_id = self.get_active_stack_id().ok_or_else(|| {
CascadeError::config("No active stack (current branch doesn't belong to any stack)")
})?;
let stack = self
.stacks
.get_mut(&stack_id)
.ok_or_else(|| CascadeError::config("Active stack not found"))?;
let entry = stack
.pop_entry()
.ok_or_else(|| CascadeError::config("Stack is empty"))?;
self.metadata.remove_commit(&entry.commit_hash);
if let Some(stack_meta) = self.metadata.get_stack_mut(&stack_id) {
stack_meta.remove_commit(&entry.commit_hash);
}
self.save_to_disk()?;
Ok(entry)
}
pub fn submit_entry(
&mut self,
stack_id: &Uuid,
entry_id: &Uuid,
pull_request_id: String,
) -> Result<()> {
let stack = self
.stacks
.get_mut(stack_id)
.ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
let entry_commit_hash = {
let entry = stack
.get_entry(entry_id)
.ok_or_else(|| CascadeError::config(format!("Entry {entry_id} not found")))?;
entry.commit_hash.clone()
};
if !stack.mark_entry_submitted(entry_id, pull_request_id.clone()) {
return Err(CascadeError::config(format!(
"Failed to mark entry {entry_id} as submitted"
)));
}
if let Some(commit_meta) = self.metadata.commits.get_mut(&entry_commit_hash) {
commit_meta.mark_submitted(pull_request_id);
}
if let Some(stack_meta) = self.metadata.get_stack_mut(stack_id) {
let submitted_count = stack.entries.iter().filter(|e| e.is_submitted).count();
let merged_count = stack.entries.iter().filter(|e| e.is_merged).count();
stack_meta.update_stats(stack.entries.len(), submitted_count, merged_count);
}
self.save_to_disk()?;
Ok(())
}
pub fn remove_stack_entry(
&mut self,
stack_id: &Uuid,
entry_id: &Uuid,
) -> Result<Option<StackEntry>> {
let stack = match self.stacks.get_mut(stack_id) {
Some(stack) => stack,
None => return Err(CascadeError::config(format!("Stack {stack_id} not found"))),
};
let entry = match stack.entry_map.get(entry_id) {
Some(entry) => entry.clone(),
None => return Ok(None),
};
if !entry.children.is_empty() {
warn!(
"Skipping removal of stack entry {} (branch '{}') because it still has {} child entr{}",
entry.id,
entry.branch,
entry.children.len(),
if entry.children.len() == 1 { "y" } else { "ies" }
);
return Ok(None);
}
stack.entries.retain(|e| e.id != entry.id);
stack.entry_map.remove(&entry.id);
if let Some(parent_id) = entry.parent_id {
if let Some(parent) = stack.entry_map.get_mut(&parent_id) {
parent.children.retain(|child| child != &entry.id);
}
}
stack.repair_data_consistency();
stack.updated_at = Utc::now();
self.metadata.remove_commit(&entry.commit_hash);
if let Some(stack_meta) = self.metadata.get_stack_mut(stack_id) {
stack_meta.remove_commit(&entry.commit_hash);
stack_meta.remove_branch(&entry.branch);
let submitted = stack.entries.iter().filter(|e| e.is_submitted).count();
let merged = stack.entries.iter().filter(|e| e.is_merged).count();
stack_meta.update_stats(stack.entries.len(), submitted, merged);
}
self.save_to_disk()?;
Ok(Some(entry))
}
pub fn remove_stack_entry_at(
&mut self,
stack_id: &Uuid,
index: usize,
) -> Result<Option<StackEntry>> {
let stack = match self.stacks.get_mut(stack_id) {
Some(stack) => stack,
None => return Err(CascadeError::config(format!("Stack {stack_id} not found"))),
};
let entry = match stack.remove_entry_at(index) {
Some(entry) => entry,
None => return Ok(None),
};
self.metadata.remove_commit(&entry.commit_hash);
if let Some(stack_meta) = self.metadata.get_stack_mut(stack_id) {
stack_meta.remove_commit(&entry.commit_hash);
stack_meta.remove_branch(&entry.branch);
let submitted = stack.entries.iter().filter(|e| e.is_submitted).count();
let merged = stack.entries.iter().filter(|e| e.is_merged).count();
stack_meta.update_stats(stack.entries.len(), submitted, merged);
}
self.save_to_disk()?;
Ok(Some(entry))
}
pub fn set_entry_merged(
&mut self,
stack_id: &Uuid,
entry_id: &Uuid,
merged: bool,
) -> Result<()> {
let stack = self
.stacks
.get_mut(stack_id)
.ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
let current_entry = stack
.entry_map
.get(entry_id)
.cloned()
.ok_or_else(|| CascadeError::config(format!("Entry {entry_id} not found")))?;
if current_entry.is_merged == merged {
return Ok(());
}
if !stack.mark_entry_merged(entry_id, merged) {
return Err(CascadeError::config(format!(
"Entry {entry_id} not found in stack {stack_id}"
)));
}
if let Some(commit_meta) = self.metadata.commits.get_mut(¤t_entry.commit_hash) {
commit_meta.mark_merged(merged);
}
if let Some(stack_meta) = self.metadata.get_stack_mut(stack_id) {
let submitted_count = stack.entries.iter().filter(|e| e.is_submitted).count();
let merged_count = stack.entries.iter().filter(|e| e.is_merged).count();
stack_meta.update_stats(stack.entries.len(), submitted_count, merged_count);
}
self.save_to_disk()?;
Ok(())
}
pub fn repair_all_stacks(&mut self) -> Result<()> {
for stack in self.stacks.values_mut() {
stack.repair_data_consistency();
}
self.save_to_disk()?;
Ok(())
}
pub fn get_all_stacks(&self) -> Vec<&Stack> {
self.stacks.values().collect()
}
pub fn get_stack_metadata(&self, stack_id: &Uuid) -> Option<&StackMetadata> {
self.metadata.get_stack(stack_id)
}
pub fn get_repository_metadata(&self) -> &RepositoryMetadata {
&self.metadata
}
pub fn git_repo(&self) -> &GitRepository {
&self.repo
}
pub fn repo_path(&self) -> &Path {
&self.repo_path
}
pub fn is_in_edit_mode(&self) -> bool {
self.metadata
.edit_mode
.as_ref()
.map(|edit_state| edit_state.is_active)
.unwrap_or(false)
}
pub fn get_edit_mode_info(&self) -> Option<&super::metadata::EditModeState> {
self.metadata.edit_mode.as_ref()
}
pub fn enter_edit_mode(&mut self, stack_id: Uuid, entry_id: Uuid) -> Result<()> {
let commit_hash = {
let stack = self
.get_stack(&stack_id)
.ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
let entry = stack.get_entry(&entry_id).ok_or_else(|| {
CascadeError::config(format!("Entry {entry_id} not found in stack"))
})?;
entry.commit_hash.clone()
};
if self.is_in_edit_mode() {
self.exit_edit_mode()?;
}
let edit_state = super::metadata::EditModeState::new(stack_id, entry_id, commit_hash);
self.metadata.edit_mode = Some(edit_state);
self.save_to_disk()?;
debug!(
"Entered edit mode for entry {} in stack {}",
entry_id, stack_id
);
Ok(())
}
pub fn exit_edit_mode(&mut self) -> Result<()> {
if !self.is_in_edit_mode() {
return Err(CascadeError::config("Not currently in edit mode"));
}
self.metadata.edit_mode = None;
self.save_to_disk()?;
debug!("Exited edit mode");
Ok(())
}
pub fn sync_stack(&mut self, stack_id: &Uuid) -> Result<()> {
let stack = self
.stacks
.get_mut(stack_id)
.ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
if let Err(integrity_error) = stack.validate_git_integrity(&self.repo) {
stack.update_status(StackStatus::Corrupted);
return Err(CascadeError::branch(format!(
"Stack '{}' Git integrity check failed:\n{}",
stack.name, integrity_error
)));
}
let mut missing_commits = Vec::new();
for entry in &stack.entries {
if !self.repo.commit_exists(&entry.commit_hash)? {
missing_commits.push(entry.commit_hash.clone());
}
}
if !missing_commits.is_empty() {
stack.update_status(StackStatus::Corrupted);
return Err(CascadeError::branch(format!(
"Stack {} has missing commits: {}",
stack.name,
missing_commits.join(", ")
)));
}
if !self.repo.branch_exists_or_fetch(&stack.base_branch)? {
return Err(CascadeError::branch(format!(
"Base branch '{}' does not exist locally or remotely. Check the branch name or switch to a different base.",
stack.base_branch
)));
}
let _base_hash = self.repo.get_branch_head(&stack.base_branch)?;
let mut corrupted_entry = None;
for entry in &stack.entries {
if !self.repo.commit_exists(&entry.commit_hash)? {
corrupted_entry = Some((entry.commit_hash.clone(), entry.branch.clone()));
break;
}
}
if let Some((commit_hash, branch)) = corrupted_entry {
stack.update_status(StackStatus::Corrupted);
return Err(CascadeError::branch(format!(
"Commit {commit_hash} from stack entry '{branch}' no longer exists"
)));
}
let needs_sync = if let Some(first_entry) = stack.entries.first() {
match self
.repo
.get_commits_between(&stack.base_branch, &first_entry.commit_hash)
{
Ok(commits) => !commits.is_empty(), Err(_) => true, }
} else {
false };
if needs_sync {
stack.update_status(StackStatus::NeedsSync);
debug!(
"Stack '{}' needs sync - new commits on base branch",
stack.name
);
} else {
stack.update_status(StackStatus::Clean);
debug!("Stack '{}' is clean", stack.name);
}
if let Some(stack_meta) = self.metadata.get_stack_mut(stack_id) {
stack_meta.set_up_to_date(true);
}
self.save_to_disk()?;
Ok(())
}
pub fn list_stacks(&self) -> Vec<(Uuid, &str, &StackStatus, usize, Option<&str>)> {
let active_id = self.get_active_stack_id();
self.stacks
.values()
.map(|stack| {
(
stack.id,
stack.name.as_str(),
&stack.status,
stack.entries.len(),
if active_id == Some(stack.id) {
Some("active")
} else {
None
},
)
})
.collect()
}
pub fn get_all_stacks_objects(&self) -> Result<Vec<Stack>> {
let active_id = self.get_active_stack_id();
let mut stacks: Vec<Stack> = self.stacks.values().cloned().collect();
for stack in &mut stacks {
stack.is_active = active_id == Some(stack.id);
}
stacks.sort_by(|a, b| a.name.cmp(&b.name));
Ok(stacks)
}
pub fn validate_all(&self) -> Result<()> {
for stack in self.stacks.values() {
stack.validate().map_err(|e| {
CascadeError::config(format!("Stack '{}' validation failed: {}", stack.name, e))
})?;
stack.validate_git_integrity(&self.repo).map_err(|e| {
CascadeError::config(format!(
"Stack '{}' Git integrity validation failed: {}",
stack.name, e
))
})?;
}
Ok(())
}
pub fn validate_stack(&self, stack_id: &Uuid) -> Result<()> {
let stack = self
.stacks
.get(stack_id)
.ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
stack.validate().map_err(|e| {
CascadeError::config(format!("Stack '{}' validation failed: {}", stack.name, e))
})?;
stack.validate_git_integrity(&self.repo).map_err(|e| {
CascadeError::config(format!(
"Stack '{}' Git integrity validation failed: {}",
stack.name, e
))
})?;
Ok(())
}
pub fn save_to_disk(&self) -> Result<()> {
if !self.config_dir.exists() {
fs::create_dir_all(&self.config_dir).map_err(|e| {
CascadeError::config(format!("Failed to create config directory: {e}"))
})?;
}
crate::utils::atomic_file::write_json(&self.stacks_file, &self.stacks)?;
crate::utils::atomic_file::write_json(&self.metadata_file, &self.metadata)?;
Ok(())
}
fn load_from_disk(&mut self) -> Result<()> {
if self.stacks_file.exists() {
let stacks_content = fs::read_to_string(&self.stacks_file)
.map_err(|e| CascadeError::config(format!("Failed to read stacks file: {e}")))?;
self.stacks = serde_json::from_str(&stacks_content)
.map_err(|e| CascadeError::config(format!("Failed to parse stacks file: {e}")))?;
}
if self.metadata_file.exists() {
let metadata_content = fs::read_to_string(&self.metadata_file)
.map_err(|e| CascadeError::config(format!("Failed to read metadata file: {e}")))?;
self.metadata = serde_json::from_str(&metadata_content)
.map_err(|e| CascadeError::config(format!("Failed to parse metadata file: {e}")))?;
}
Ok(())
}
pub fn handle_branch_modifications(
&mut self,
stack_id: &Uuid,
auto_mode: Option<String>,
) -> Result<()> {
let stack = self
.stacks
.get_mut(stack_id)
.ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
debug!("Checking Git integrity for stack '{}'", stack.name);
let mut modifications = Vec::new();
for entry in &stack.entries {
if !self.repo.branch_exists(&entry.branch) {
modifications.push(BranchModification::Missing {
branch: entry.branch.clone(),
entry_id: entry.id,
expected_commit: entry.commit_hash.clone(),
});
} else if let Ok(branch_head) = self.repo.get_branch_head(&entry.branch) {
if branch_head != entry.commit_hash {
let extra_commits = self
.repo
.get_commits_between(&entry.commit_hash, &branch_head)?;
let mut extra_messages = Vec::new();
for commit in &extra_commits {
if let Some(message) = commit.message() {
let first_line =
message.lines().next().unwrap_or("(no message)").to_string();
extra_messages.push(format!(
"{}: {}",
&commit.id().to_string()[..8],
first_line
));
}
}
modifications.push(BranchModification::ExtraCommits {
branch: entry.branch.clone(),
entry_id: entry.id,
expected_commit: entry.commit_hash.clone(),
actual_commit: branch_head,
extra_commit_count: extra_commits.len(),
extra_commit_messages: extra_messages,
});
}
}
}
if modifications.is_empty() {
return Ok(());
}
println!();
Output::section(format!("Branch modifications detected in '{}'", stack.name));
for (i, modification) in modifications.iter().enumerate() {
match modification {
BranchModification::Missing { branch, .. } => {
Output::numbered_item(i + 1, format!("Branch '{branch}' is missing"));
}
BranchModification::ExtraCommits {
branch,
expected_commit,
actual_commit,
extra_commit_count,
extra_commit_messages,
..
} => {
println!(
" {}. Branch '{}' has {} extra commit(s)",
i + 1,
branch,
extra_commit_count
);
println!(
" Expected: {} | Actual: {}",
&expected_commit[..8],
&actual_commit[..8]
);
for (j, message) in extra_commit_messages.iter().enumerate() {
match j.cmp(&3) {
std::cmp::Ordering::Less => {
Output::sub_item(format!(" + {message}"));
}
std::cmp::Ordering::Equal => {
Output::sub_item(format!(
" + ... and {} more",
extra_commit_count - 3
));
break;
}
std::cmp::Ordering::Greater => {
break;
}
}
}
}
}
}
Output::spacing();
if let Some(mode) = auto_mode {
return self.apply_auto_fix(stack_id, &modifications, &mode);
}
let mut handled_count = 0;
let mut skipped_count = 0;
for modification in modifications.iter() {
let was_skipped = self.handle_single_modification(stack_id, modification)?;
if was_skipped {
skipped_count += 1;
} else {
handled_count += 1;
}
}
self.save_to_disk()?;
if skipped_count == 0 {
Output::success("All branch modifications resolved");
} else if handled_count > 0 {
Output::warning(format!(
"Resolved {} modification(s), {} skipped",
handled_count, skipped_count
));
} else {
Output::warning("All modifications skipped - integrity issues remain");
}
Ok(())
}
fn handle_single_modification(
&mut self,
stack_id: &Uuid,
modification: &BranchModification,
) -> Result<bool> {
match modification {
BranchModification::Missing {
branch,
expected_commit,
..
} => {
Output::info(format!("Missing branch '{branch}'"));
Output::sub_item(format!(
"Will create the branch at commit {}",
&expected_commit[..8]
));
self.repo.create_branch(branch, Some(expected_commit))?;
Output::success(format!("Created branch '{branch}'"));
Ok(false) }
BranchModification::ExtraCommits {
branch,
entry_id,
expected_commit,
extra_commit_count,
..
} => {
println!();
Output::info(format!(
"Branch '{}' has {} extra commit(s)",
branch, extra_commit_count
));
let options = vec![
"Incorporate - Update stack entry to include extra commits",
"Split - Create new stack entry for extra commits",
"Reset - Remove extra commits (DESTRUCTIVE)",
"Skip - Leave as-is for now",
];
let choice = Select::with_theme(&ColorfulTheme::default())
.with_prompt("Choose how to handle extra commits")
.default(0)
.items(&options)
.interact()
.map_err(|e| CascadeError::config(format!("Failed to get user choice: {e}")))?;
match choice {
0 => {
self.incorporate_extra_commits(stack_id, *entry_id, branch)?;
Ok(false) }
1 => {
self.split_extra_commits(stack_id, *entry_id, branch)?;
Ok(false) }
2 => {
self.reset_branch_destructive(branch, expected_commit)?;
Ok(false) }
3 => {
Output::warning(format!("Skipped '{branch}' - integrity issue remains"));
Ok(true) }
_ => {
Output::warning(format!("Invalid choice - skipped '{branch}'"));
Ok(true) }
}
}
}
}
fn apply_auto_fix(
&mut self,
stack_id: &Uuid,
modifications: &[BranchModification],
mode: &str,
) -> Result<()> {
Output::info(format!("🤖 Applying automatic fix mode: {mode}"));
for modification in modifications {
match (modification, mode) {
(
BranchModification::Missing {
branch,
expected_commit,
..
},
_,
) => {
self.repo.create_branch(branch, Some(expected_commit))?;
Output::success(format!("Created missing branch '{branch}'"));
}
(
BranchModification::ExtraCommits {
branch, entry_id, ..
},
"incorporate",
) => {
self.incorporate_extra_commits(stack_id, *entry_id, branch)?;
}
(
BranchModification::ExtraCommits {
branch, entry_id, ..
},
"split",
) => {
self.split_extra_commits(stack_id, *entry_id, branch)?;
}
(
BranchModification::ExtraCommits {
branch,
expected_commit,
..
},
"reset",
) => {
self.reset_branch_destructive(branch, expected_commit)?;
}
_ => {
return Err(CascadeError::config(format!(
"Unknown auto-fix mode '{mode}'. Use: incorporate, split, reset"
)));
}
}
}
self.save_to_disk()?;
Output::success(format!("Auto-fix completed for mode: {mode}"));
Ok(())
}
fn incorporate_extra_commits(
&mut self,
stack_id: &Uuid,
entry_id: Uuid,
branch: &str,
) -> Result<()> {
let stack = self
.stacks
.get_mut(stack_id)
.ok_or_else(|| CascadeError::config(format!("Stack not found: {}", stack_id)))?;
let entry_info = stack
.entries
.iter()
.find(|e| e.id == entry_id)
.map(|e| (e.commit_hash.clone(), e.id));
if let Some((old_commit_hash, entry_id)) = entry_info {
let new_head = self.repo.get_branch_head(branch)?;
let old_commit = old_commit_hash[..8].to_string();
let extra_commits = self.repo.get_commits_between(&old_commit_hash, &new_head)?;
stack
.update_entry_commit_hash(&entry_id, new_head.clone())
.map_err(CascadeError::config)?;
Output::success(format!(
"Incorporated {} commit(s) into entry '{}'",
extra_commits.len(),
&new_head[..8]
));
Output::sub_item(format!("Updated: {} -> {}", old_commit, &new_head[..8]));
}
Ok(())
}
fn split_extra_commits(&mut self, stack_id: &Uuid, entry_id: Uuid, branch: &str) -> Result<()> {
let stack = self
.stacks
.get_mut(stack_id)
.ok_or_else(|| CascadeError::config(format!("Stack not found: {}", stack_id)))?;
let new_head = self.repo.get_branch_head(branch)?;
let entry_position = stack
.entries
.iter()
.position(|e| e.id == entry_id)
.ok_or_else(|| CascadeError::config("Entry not found in stack"))?;
let base_name = branch.trim_end_matches(|c: char| c.is_ascii_digit() || c == '-');
let new_branch = format!("{base_name}-continued");
self.repo.create_branch(&new_branch, Some(&new_head))?;
let original_entry = &stack.entries[entry_position];
let original_commit_hash = original_entry.commit_hash.clone(); let extra_commits = self
.repo
.get_commits_between(&original_commit_hash, &new_head)?;
let mut extra_messages = Vec::new();
for commit in &extra_commits {
if let Some(message) = commit.message() {
let first_line = message.lines().next().unwrap_or("").to_string();
extra_messages.push(first_line);
}
}
let new_message = if extra_messages.len() == 1 {
extra_messages[0].clone()
} else {
format!("Combined changes:\n• {}", extra_messages.join("\n• "))
};
let now = Utc::now();
let new_entry = crate::stack::StackEntry {
id: uuid::Uuid::new_v4(),
branch: new_branch.clone(),
commit_hash: new_head,
message: new_message,
parent_id: Some(entry_id), children: Vec::new(),
created_at: now,
updated_at: now,
is_submitted: false,
pull_request_id: None,
is_synced: false,
is_merged: false,
};
stack.entries.insert(entry_position + 1, new_entry);
self.repo
.reset_branch_to_commit(branch, &original_commit_hash)?;
println!(
" ✅ Split {} commit(s) into new entry '{}'",
extra_commits.len(),
new_branch
);
println!(" Original branch '{branch}' reset to expected commit");
Ok(())
}
fn reset_branch_destructive(&self, branch: &str, expected_commit: &str) -> Result<()> {
self.repo.reset_branch_to_commit(branch, expected_commit)?;
Output::warning(format!(
"Reset branch '{}' to {} (extra commits lost)",
branch,
&expected_commit[..8]
));
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command;
use tempfile::TempDir;
fn create_test_repo() -> (TempDir, PathBuf) {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path().to_path_buf();
Command::new("git")
.args(["init"])
.current_dir(&repo_path)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(&repo_path)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(&repo_path)
.output()
.unwrap();
std::fs::write(repo_path.join("README.md"), "# Test Repo").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(&repo_path)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "Initial commit"])
.current_dir(&repo_path)
.output()
.unwrap();
crate::config::initialize_repo(&repo_path, Some("https://test.bitbucket.com".to_string()))
.unwrap();
(temp_dir, repo_path)
}
#[test]
fn test_create_stack_manager() {
let (_temp_dir, repo_path) = create_test_repo();
let manager = StackManager::new(&repo_path).unwrap();
assert_eq!(manager.stacks.len(), 0);
assert!(manager.get_active_stack().is_none());
}
#[test]
fn test_create_and_manage_stack() {
let (_temp_dir, repo_path) = create_test_repo();
Command::new("git")
.args(["checkout", "-b", "feature/test-work"])
.current_dir(&repo_path)
.output()
.unwrap();
let mut manager = StackManager::new(&repo_path).unwrap();
let stack_id = manager
.create_stack(
"test-stack".to_string(),
None, Some("Test stack description".to_string()),
)
.unwrap();
assert_eq!(manager.stacks.len(), 1);
let stack = manager.get_stack(&stack_id).unwrap();
assert_eq!(stack.name, "test-stack");
assert!(!stack.base_branch.is_empty());
assert_eq!(stack.working_branch.as_deref(), Some("feature/test-work"));
let active = manager.get_active_stack().unwrap();
assert_eq!(active.id, stack_id);
let found = manager.get_stack_by_name("test-stack").unwrap();
assert_eq!(found.id, stack_id);
}
#[test]
fn test_stack_persistence() {
let (_temp_dir, repo_path) = create_test_repo();
Command::new("git")
.args(["checkout", "-b", "feature/persist-work"])
.current_dir(&repo_path)
.output()
.unwrap();
let stack_id = {
let mut manager = StackManager::new(&repo_path).unwrap();
manager
.create_stack("persistent-stack".to_string(), None, None)
.unwrap()
};
let manager = StackManager::new(&repo_path).unwrap();
assert_eq!(manager.stacks.len(), 1);
let stack = manager.get_stack(&stack_id).unwrap();
assert_eq!(stack.name, "persistent-stack");
}
#[test]
fn test_multiple_stacks() {
let (_temp_dir, repo_path) = create_test_repo();
let mut manager = StackManager::new(&repo_path).unwrap();
Command::new("git")
.args(["checkout", "-b", "feature/stack-1"])
.current_dir(&repo_path)
.output()
.unwrap();
let stack1_id = manager
.create_stack("stack-1".to_string(), None, None)
.unwrap();
Command::new("git")
.args(["checkout", "-b", "feature/stack-2"])
.current_dir(&repo_path)
.output()
.unwrap();
let stack2_id = manager
.create_stack("stack-2".to_string(), None, None)
.unwrap();
assert_eq!(manager.stacks.len(), 2);
assert_eq!(manager.get_active_stack_id(), Some(stack2_id));
Command::new("git")
.args(["checkout", "feature/stack-1"])
.current_dir(&repo_path)
.output()
.unwrap();
let manager = StackManager::new(&repo_path).unwrap();
assert_eq!(manager.get_active_stack_id(), Some(stack1_id));
}
#[test]
fn test_delete_stack() {
let (_temp_dir, repo_path) = create_test_repo();
let mut manager = StackManager::new(&repo_path).unwrap();
let stack_id = manager
.create_stack("to-delete".to_string(), None, None)
.unwrap();
assert_eq!(manager.stacks.len(), 1);
let deleted = manager.delete_stack(&stack_id).unwrap();
assert_eq!(deleted.name, "to-delete");
assert_eq!(manager.stacks.len(), 0);
assert!(manager.get_active_stack().is_none());
}
#[test]
fn test_validation() {
let (_temp_dir, repo_path) = create_test_repo();
let mut manager = StackManager::new(&repo_path).unwrap();
manager
.create_stack("valid-stack".to_string(), None, None)
.unwrap();
assert!(manager.validate_all().is_ok());
}
#[test]
fn test_duplicate_commit_message_detection() {
let (_temp_dir, repo_path) = create_test_repo();
Command::new("git")
.args(["checkout", "-b", "feature/test-dup"])
.current_dir(&repo_path)
.output()
.unwrap();
let mut manager = StackManager::new(&repo_path).unwrap();
manager
.create_stack("test-stack".to_string(), None, None)
.unwrap();
std::fs::write(repo_path.join("file1.txt"), "content1").unwrap();
Command::new("git")
.args(["add", "file1.txt"])
.current_dir(&repo_path)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "Add authentication feature"])
.current_dir(&repo_path)
.output()
.unwrap();
let commit1_hash = Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(&repo_path)
.output()
.unwrap();
let commit1_hash = String::from_utf8_lossy(&commit1_hash.stdout)
.trim()
.to_string();
let entry1_id = manager
.push_to_stack(
"feature/auth".to_string(),
commit1_hash,
"Add authentication feature".to_string(),
"main".to_string(),
)
.unwrap();
assert!(manager
.get_active_stack()
.unwrap()
.get_entry(&entry1_id)
.is_some());
std::fs::write(repo_path.join("file2.txt"), "content2").unwrap();
Command::new("git")
.args(["add", "file2.txt"])
.current_dir(&repo_path)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "Different commit message"])
.current_dir(&repo_path)
.output()
.unwrap();
let commit2_hash = Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(&repo_path)
.output()
.unwrap();
let commit2_hash = String::from_utf8_lossy(&commit2_hash.stdout)
.trim()
.to_string();
let result = manager.push_to_stack(
"feature/auth2".to_string(),
commit2_hash.clone(),
"Add authentication feature".to_string(), "main".to_string(),
);
assert!(result.is_err());
let error = result.unwrap_err();
assert!(matches!(error, CascadeError::Validation(_)));
let error_msg = error.to_string();
assert!(error_msg.contains("Duplicate commit message"));
assert!(error_msg.contains("Add authentication feature"));
assert!(error_msg.contains("💡 Consider using a more specific message"));
let entry2_id = manager
.push_to_stack(
"feature/auth2".to_string(),
commit2_hash,
"Add authentication validation".to_string(), "main".to_string(),
)
.unwrap();
let stack = manager.get_active_stack().unwrap();
assert_eq!(stack.entries.len(), 2);
assert!(stack.get_entry(&entry1_id).is_some());
assert!(stack.get_entry(&entry2_id).is_some());
}
#[test]
fn test_duplicate_message_with_different_case() {
let (_temp_dir, repo_path) = create_test_repo();
Command::new("git")
.args(["checkout", "-b", "feature/test-case"])
.current_dir(&repo_path)
.output()
.unwrap();
let mut manager = StackManager::new(&repo_path).unwrap();
manager
.create_stack("test-stack".to_string(), None, None)
.unwrap();
std::fs::write(repo_path.join("file1.txt"), "content1").unwrap();
Command::new("git")
.args(["add", "file1.txt"])
.current_dir(&repo_path)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "fix bug"])
.current_dir(&repo_path)
.output()
.unwrap();
let commit1_hash = Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(&repo_path)
.output()
.unwrap();
let commit1_hash = String::from_utf8_lossy(&commit1_hash.stdout)
.trim()
.to_string();
manager
.push_to_stack(
"feature/fix1".to_string(),
commit1_hash,
"fix bug".to_string(),
"main".to_string(),
)
.unwrap();
std::fs::write(repo_path.join("file2.txt"), "content2").unwrap();
Command::new("git")
.args(["add", "file2.txt"])
.current_dir(&repo_path)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "Fix Bug"])
.current_dir(&repo_path)
.output()
.unwrap();
let commit2_hash = Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(&repo_path)
.output()
.unwrap();
let commit2_hash = String::from_utf8_lossy(&commit2_hash.stdout)
.trim()
.to_string();
let result = manager.push_to_stack(
"feature/fix2".to_string(),
commit2_hash,
"Fix Bug".to_string(), "main".to_string(),
);
assert!(result.is_ok());
}
#[test]
fn test_duplicate_message_across_different_stacks() {
let (_temp_dir, repo_path) = create_test_repo();
Command::new("git")
.args(["checkout", "-b", "feature/stack1-work"])
.current_dir(&repo_path)
.output()
.unwrap();
let mut manager = StackManager::new(&repo_path).unwrap();
let stack1_id = manager
.create_stack("stack1".to_string(), None, None)
.unwrap();
std::fs::write(repo_path.join("file1.txt"), "content1").unwrap();
Command::new("git")
.args(["add", "file1.txt"])
.current_dir(&repo_path)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "shared message"])
.current_dir(&repo_path)
.output()
.unwrap();
let commit1_hash = Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(&repo_path)
.output()
.unwrap();
let commit1_hash = String::from_utf8_lossy(&commit1_hash.stdout)
.trim()
.to_string();
manager
.push_to_stack(
"feature/shared1".to_string(),
commit1_hash,
"shared message".to_string(),
"main".to_string(),
)
.unwrap();
Command::new("git")
.args(["checkout", "-b", "feature/stack2-work"])
.current_dir(&repo_path)
.output()
.unwrap();
let stack2_id = manager
.create_stack("stack2".to_string(), None, None)
.unwrap();
let mut manager = StackManager::new(&repo_path).unwrap();
std::fs::write(repo_path.join("file2.txt"), "content2").unwrap();
Command::new("git")
.args(["add", "file2.txt"])
.current_dir(&repo_path)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "shared message"])
.current_dir(&repo_path)
.output()
.unwrap();
let commit2_hash = Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(&repo_path)
.output()
.unwrap();
let commit2_hash = String::from_utf8_lossy(&commit2_hash.stdout)
.trim()
.to_string();
let result = manager.push_to_stack(
"feature/shared2".to_string(),
commit2_hash,
"shared message".to_string(), "main".to_string(),
);
assert!(result.is_ok());
let stack1 = manager.get_stack(&stack1_id).unwrap();
let stack2 = manager.get_stack(&stack2_id).unwrap();
assert_eq!(stack1.entries.len(), 1);
assert_eq!(stack2.entries.len(), 1);
assert_eq!(stack1.entries[0].message, "shared message");
assert_eq!(stack2.entries[0].message, "shared message");
}
#[test]
fn test_duplicate_after_pop() {
let (_temp_dir, repo_path) = create_test_repo();
Command::new("git")
.args(["checkout", "-b", "feature/test-pop"])
.current_dir(&repo_path)
.output()
.unwrap();
let mut manager = StackManager::new(&repo_path).unwrap();
manager
.create_stack("test-stack".to_string(), None, None)
.unwrap();
std::fs::write(repo_path.join("file1.txt"), "content1").unwrap();
Command::new("git")
.args(["add", "file1.txt"])
.current_dir(&repo_path)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "temporary message"])
.current_dir(&repo_path)
.output()
.unwrap();
let commit1_hash = Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(&repo_path)
.output()
.unwrap();
let commit1_hash = String::from_utf8_lossy(&commit1_hash.stdout)
.trim()
.to_string();
manager
.push_to_stack(
"feature/temp".to_string(),
commit1_hash,
"temporary message".to_string(),
"main".to_string(),
)
.unwrap();
let popped = manager.pop_from_stack().unwrap();
assert_eq!(popped.message, "temporary message");
std::fs::write(repo_path.join("file2.txt"), "content2").unwrap();
Command::new("git")
.args(["add", "file2.txt"])
.current_dir(&repo_path)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "temporary message"])
.current_dir(&repo_path)
.output()
.unwrap();
let commit2_hash = Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(&repo_path)
.output()
.unwrap();
let commit2_hash = String::from_utf8_lossy(&commit2_hash.stdout)
.trim()
.to_string();
let result = manager.push_to_stack(
"feature/temp2".to_string(),
commit2_hash,
"temporary message".to_string(),
"main".to_string(),
);
assert!(result.is_ok());
}
}