cascade_cli/stack/
manager.rs

1use super::metadata::RepositoryMetadata;
2use super::{CommitMetadata, Stack, StackEntry, StackMetadata, StackStatus};
3use crate::cli::output::Output;
4use crate::config::{get_repo_config_dir, Settings};
5use crate::errors::{CascadeError, Result};
6use crate::git::GitRepository;
7use dialoguer::{theme::ColorfulTheme, Input, Select};
8use std::collections::HashMap;
9use std::fs;
10use std::path::{Path, PathBuf};
11use tracing::debug;
12use uuid::Uuid;
13
14/// Types of branch modifications detected during Git integrity checks
15#[derive(Debug)]
16pub enum BranchModification {
17    /// Branch is missing (needs to be created)
18    Missing {
19        branch: String,
20        entry_id: Uuid,
21        expected_commit: String,
22    },
23    /// Branch has extra commits beyond what's expected
24    ExtraCommits {
25        branch: String,
26        entry_id: Uuid,
27        expected_commit: String,
28        actual_commit: String,
29        extra_commit_count: usize,
30        extra_commit_messages: Vec<String>,
31    },
32}
33
34/// Manages all stack operations and persistence
35pub struct StackManager {
36    /// Git repository interface
37    repo: GitRepository,
38    /// Path to the repository root
39    repo_path: PathBuf,
40    /// Path to cascade config directory
41    config_dir: PathBuf,
42    /// Path to stacks data file
43    stacks_file: PathBuf,
44    /// Path to metadata file
45    metadata_file: PathBuf,
46    /// In-memory stack data
47    stacks: HashMap<Uuid, Stack>,
48    /// Repository metadata
49    metadata: RepositoryMetadata,
50}
51
52impl StackManager {
53    /// Create a new StackManager for the given repository
54    pub fn new(repo_path: &Path) -> Result<Self> {
55        let repo = GitRepository::open(repo_path)?;
56        let config_dir = get_repo_config_dir(repo_path)?;
57        let stacks_file = config_dir.join("stacks.json");
58        let metadata_file = config_dir.join("metadata.json");
59
60        // Load configuration to get the configured default branch
61        let config_file = config_dir.join("config.json");
62        let settings = Settings::load_from_file(&config_file).unwrap_or_default();
63        let configured_default = &settings.git.default_branch;
64
65        // Using configured default branch
66
67        // Determine default base branch - use configured default if it exists
68        let default_base = if repo.branch_exists(configured_default) {
69            // Found configured default branch locally
70            configured_default.clone()
71        } else {
72            // Fall back to detecting a suitable branch
73            match repo.detect_main_branch() {
74                Ok(detected) => {
75                    // Configured default branch not found, using detected branch
76                    detected
77                }
78                Err(_) => {
79                    // Use configured default even if it doesn't exist yet (might be created later)
80                    // Using configured default branch even though it doesn't exist locally
81                    configured_default.clone()
82                }
83            }
84        };
85
86        let mut manager = Self {
87            repo,
88            repo_path: repo_path.to_path_buf(),
89            config_dir,
90            stacks_file,
91            metadata_file,
92            stacks: HashMap::new(),
93            metadata: RepositoryMetadata::new(default_base),
94        };
95
96        // Load existing data if available
97        manager.load_from_disk()?;
98
99        Ok(manager)
100    }
101
102    /// Create a new stack
103    pub fn create_stack(
104        &mut self,
105        name: String,
106        base_branch: Option<String>,
107        description: Option<String>,
108    ) -> Result<Uuid> {
109        // Check if stack with this name already exists
110        if self.metadata.find_stack_by_name(&name).is_some() {
111            return Err(CascadeError::config(format!(
112                "Stack '{name}' already exists"
113            )));
114        }
115
116        // Use provided base branch, or try to detect parent branch, or fall back to default
117        let base_branch = base_branch.unwrap_or_else(|| {
118            // Try to detect the parent branch of the current branch
119            if let Ok(Some(detected_parent)) = self.repo.detect_parent_branch() {
120                detected_parent
121            } else {
122                // Fall back to default base branch
123                self.metadata.default_base_branch.clone()
124            }
125        });
126
127        // Verify base branch exists (try to fetch from remote if not local)
128        if !self.repo.branch_exists_or_fetch(&base_branch)? {
129            return Err(CascadeError::branch(format!(
130                "Base branch '{base_branch}' does not exist locally or remotely"
131            )));
132        }
133
134        // Get current branch as the working branch
135        let current_branch = self.repo.get_current_branch().ok();
136
137        // Create the stack
138        let mut stack = Stack::new(name.clone(), base_branch.clone(), description.clone());
139
140        // Set working branch if we're on a feature branch (not on base branch)
141        if let Some(ref branch) = current_branch {
142            if branch != &base_branch {
143                stack.working_branch = Some(branch.clone());
144            }
145        }
146
147        let stack_id = stack.id;
148
149        // Create metadata
150        let stack_metadata = StackMetadata::new(stack_id, name, base_branch, description);
151
152        // Store in memory
153        self.stacks.insert(stack_id, stack);
154        self.metadata.add_stack(stack_metadata);
155
156        // Always set newly created stack as active
157        self.set_active_stack(Some(stack_id))?;
158
159        Ok(stack_id)
160    }
161
162    /// Get a stack by ID
163    pub fn get_stack(&self, stack_id: &Uuid) -> Option<&Stack> {
164        self.stacks.get(stack_id)
165    }
166
167    /// Get a mutable stack by ID
168    pub fn get_stack_mut(&mut self, stack_id: &Uuid) -> Option<&mut Stack> {
169        self.stacks.get_mut(stack_id)
170    }
171
172    /// Get stack by name
173    pub fn get_stack_by_name(&self, name: &str) -> Option<&Stack> {
174        if let Some(metadata) = self.metadata.find_stack_by_name(name) {
175            self.stacks.get(&metadata.stack_id)
176        } else {
177            None
178        }
179    }
180
181    /// Get mutable stack by name
182    pub fn get_stack_by_name_mut(&mut self, name: &str) -> Option<&mut Stack> {
183        if let Some(metadata) = self.metadata.find_stack_by_name(name) {
184            self.stacks.get_mut(&metadata.stack_id)
185        } else {
186            None
187        }
188    }
189
190    /// Update working branch for a stack
191    pub fn update_stack_working_branch(&mut self, name: &str, branch: String) -> Result<()> {
192        if let Some(stack) = self.get_stack_by_name_mut(name) {
193            stack.working_branch = Some(branch);
194            self.save_to_disk()?;
195            Ok(())
196        } else {
197            Err(CascadeError::config(format!("Stack '{name}' not found")))
198        }
199    }
200
201    /// Get the currently active stack
202    pub fn get_active_stack(&self) -> Option<&Stack> {
203        self.metadata
204            .active_stack_id
205            .and_then(|id| self.stacks.get(&id))
206    }
207
208    /// Get the currently active stack mutably
209    pub fn get_active_stack_mut(&mut self) -> Option<&mut Stack> {
210        if let Some(id) = self.metadata.active_stack_id {
211            self.stacks.get_mut(&id)
212        } else {
213            None
214        }
215    }
216
217    /// Set the active stack
218    pub fn set_active_stack(&mut self, stack_id: Option<Uuid>) -> Result<()> {
219        // Verify stack exists if provided
220        if let Some(id) = stack_id {
221            if !self.stacks.contains_key(&id) {
222                return Err(CascadeError::config(format!(
223                    "Stack with ID {id} not found"
224                )));
225            }
226        }
227
228        // Clear edit mode when switching stacks or deactivating
229        // Edit mode is tied to a specific stack context
230        if self.is_in_edit_mode() {
231            if let Some(edit_info) = self.get_edit_mode_info() {
232                // If switching to a different stack, clear edit mode
233                if edit_info.target_stack_id != stack_id {
234                    tracing::debug!(
235                        "Clearing edit mode when switching from stack {:?} to {:?}",
236                        edit_info.target_stack_id,
237                        stack_id
238                    );
239                    self.exit_edit_mode()?;
240                }
241            }
242        }
243
244        // Update active flag on stacks
245        for stack in self.stacks.values_mut() {
246            stack.set_active(Some(stack.id) == stack_id);
247        }
248
249        // Track the current branch when activating a stack
250        if let Some(id) = stack_id {
251            let current_branch = self.repo.get_current_branch().ok();
252            if let Some(stack_meta) = self.metadata.get_stack_mut(&id) {
253                stack_meta.set_current_branch(current_branch);
254            }
255        }
256
257        self.metadata.set_active_stack(stack_id);
258        self.save_to_disk()?;
259
260        Ok(())
261    }
262
263    /// Set active stack by name
264    pub fn set_active_stack_by_name(&mut self, name: &str) -> Result<()> {
265        if let Some(metadata) = self.metadata.find_stack_by_name(name) {
266            self.set_active_stack(Some(metadata.stack_id))
267        } else {
268            Err(CascadeError::config(format!("Stack '{name}' not found")))
269        }
270    }
271
272    /// Delete a stack
273    pub fn delete_stack(&mut self, stack_id: &Uuid) -> Result<Stack> {
274        let stack = self
275            .stacks
276            .remove(stack_id)
277            .ok_or_else(|| CascadeError::config(format!("Stack with ID {stack_id} not found")))?;
278
279        // Remove metadata
280        self.metadata.remove_stack(stack_id);
281
282        // Remove all associated commit metadata
283        let stack_commits: Vec<String> = self
284            .metadata
285            .commits
286            .values()
287            .filter(|commit| &commit.stack_id == stack_id)
288            .map(|commit| commit.hash.clone())
289            .collect();
290
291        for commit_hash in stack_commits {
292            self.metadata.remove_commit(&commit_hash);
293        }
294
295        // If this was the active stack, find a new one
296        if self.metadata.active_stack_id == Some(*stack_id) {
297            let new_active = self.metadata.stacks.keys().next().copied();
298            self.set_active_stack(new_active)?;
299        }
300
301        self.save_to_disk()?;
302
303        Ok(stack)
304    }
305
306    /// Push a commit to a stack
307    pub fn push_to_stack(
308        &mut self,
309        branch: String,
310        commit_hash: String,
311        message: String,
312        source_branch: String,
313    ) -> Result<Uuid> {
314        let stack_id = self
315            .metadata
316            .active_stack_id
317            .ok_or_else(|| CascadeError::config("No active stack"))?;
318
319        // šŸ†• RECONCILE METADATA: Sync entry commit hashes with current branch HEADs before validation
320        // This prevents false "branch modification" errors from stale metadata (e.g., after ca sync)
321        let mut reconciled = false;
322        {
323            let stack = self
324                .stacks
325                .get_mut(&stack_id)
326                .ok_or_else(|| CascadeError::config("Active stack not found"))?;
327
328            if !stack.entries.is_empty() {
329                for entry in &mut stack.entries {
330                    if let Ok(current_commit) = self.repo.get_branch_head(&entry.branch) {
331                        if entry.commit_hash != current_commit {
332                            debug!(
333                                "Reconciling stale metadata for '{}': updating hash from {} to {} (current branch HEAD)",
334                                entry.branch,
335                                &entry.commit_hash[..8],
336                                &current_commit[..8]
337                            );
338                            entry.commit_hash = current_commit;
339                            reconciled = true;
340                        }
341                    }
342                }
343            }
344        } // End of reconciliation scope - stack borrow dropped here
345
346        // Save reconciled metadata before validation
347        if reconciled {
348            debug!("Saving reconciled metadata before validation");
349            self.save_to_disk()?;
350        }
351
352        // šŸ†• VALIDATE GIT INTEGRITY BEFORE PUSHING (after reconciliation)
353        let stack = self
354            .stacks
355            .get_mut(&stack_id)
356            .ok_or_else(|| CascadeError::config("Active stack not found"))?;
357
358        if !stack.entries.is_empty() {
359            if let Err(integrity_error) = stack.validate_git_integrity(&self.repo) {
360                return Err(CascadeError::validation(format!(
361                    "Git integrity validation failed:\n{}\n\n\
362                     Fix the stack integrity issues first using 'ca stack validate {}' for details.",
363                    integrity_error, stack.name
364                )));
365            }
366        }
367
368        // Verify the commit exists
369        if !self.repo.commit_exists(&commit_hash)? {
370            return Err(CascadeError::branch(format!(
371                "Commit {commit_hash} does not exist"
372            )));
373        }
374
375        // Check for duplicate commit messages within the same stack
376        if let Some(duplicate_entry) = stack.entries.iter().find(|entry| entry.message == message) {
377            return Err(CascadeError::validation(format!(
378                "Duplicate commit message in stack: \"{message}\"\n\n\
379                 This message already exists in entry {} (commit: {})\n\n\
380                 šŸ’” Consider using a more specific message:\n\
381                    • Add context: \"{message} - add validation\"\n\
382                    • Be more specific: \"Fix user authentication timeout bug\"\n\
383                    • Or amend the previous commit: git commit --amend",
384                duplicate_entry.id,
385                &duplicate_entry.commit_hash[..8]
386            )));
387        }
388
389        // šŸŽÆ SMART BASE BRANCH UPDATE FOR FEATURE WORKFLOW
390        // If this is the first commit in an empty stack, and the user is on a feature branch
391        // that's different from the stack's base branch, update the base branch to match
392        // the current workflow.
393        if stack.entries.is_empty() {
394            let current_branch = self.repo.get_current_branch()?;
395
396            // Update working branch if not already set
397            if stack.working_branch.is_none() && current_branch != stack.base_branch {
398                stack.working_branch = Some(current_branch.clone());
399                tracing::info!(
400                    "Set working branch for stack '{}' to '{}'",
401                    stack.name,
402                    current_branch
403                );
404            }
405
406            if current_branch != stack.base_branch && current_branch != "HEAD" {
407                // Check if current branch was created from the stack's base branch
408                let base_exists = self.repo.branch_exists(&stack.base_branch);
409                let current_is_feature = current_branch.starts_with("feature/")
410                    || current_branch.starts_with("fix/")
411                    || current_branch.starts_with("chore/")
412                    || current_branch.contains("feature")
413                    || current_branch.contains("fix");
414
415                if base_exists && current_is_feature {
416                    tracing::info!(
417                        "šŸŽÆ First commit detected: updating stack '{}' base branch from '{}' to '{}'",
418                        stack.name, stack.base_branch, current_branch
419                    );
420
421                    Output::info("šŸŽÆ Smart Base Branch Update:");
422                    Output::sub_item(format!(
423                        "Stack '{}' was created with base '{}'",
424                        stack.name, stack.base_branch
425                    ));
426                    Output::sub_item(format!(
427                        "You're now working on feature branch '{current_branch}'"
428                    ));
429                    Output::sub_item("Updating stack base branch to match your workflow");
430
431                    // Update the stack's base branch
432                    stack.base_branch = current_branch.clone();
433
434                    // Update metadata as well
435                    if let Some(stack_meta) = self.metadata.get_stack_mut(&stack_id) {
436                        stack_meta.base_branch = current_branch.clone();
437                        stack_meta.set_current_branch(Some(current_branch.clone()));
438                    }
439
440                    println!(
441                        "   āœ… Stack '{}' base branch updated to '{current_branch}'",
442                        stack.name
443                    );
444                }
445            }
446        }
447
448        // šŸ†• CREATE ACTUAL GIT BRANCH from the specific commit
449        // Check if branch already exists
450        if self.repo.branch_exists(&branch) {
451            // Branch already exists, skip creation
452        } else {
453            // Create the branch from the specific commit hash
454            self.repo
455                .create_branch(&branch, Some(&commit_hash))
456                .map_err(|e| {
457                    CascadeError::branch(format!(
458                        "Failed to create branch '{}' from commit {}: {}",
459                        branch,
460                        &commit_hash[..8],
461                        e
462                    ))
463                })?;
464
465            // Branch creation succeeded - logging handled by caller
466        }
467
468        // Add to stack
469        let entry_id = stack.push_entry(branch.clone(), commit_hash.clone(), message.clone());
470
471        // Create commit metadata
472        let commit_metadata = CommitMetadata::new(
473            commit_hash.clone(),
474            message,
475            entry_id,
476            stack_id,
477            branch.clone(),
478            source_branch,
479        );
480
481        // Update repository metadata
482        self.metadata.add_commit(commit_metadata);
483        if let Some(stack_meta) = self.metadata.get_stack_mut(&stack_id) {
484            stack_meta.add_branch(branch);
485            stack_meta.add_commit(commit_hash);
486        }
487
488        self.save_to_disk()?;
489
490        Ok(entry_id)
491    }
492
493    /// Pop the top commit from the active stack
494    pub fn pop_from_stack(&mut self) -> Result<StackEntry> {
495        let stack_id = self
496            .metadata
497            .active_stack_id
498            .ok_or_else(|| CascadeError::config("No active stack"))?;
499
500        let stack = self
501            .stacks
502            .get_mut(&stack_id)
503            .ok_or_else(|| CascadeError::config("Active stack not found"))?;
504
505        let entry = stack
506            .pop_entry()
507            .ok_or_else(|| CascadeError::config("Stack is empty"))?;
508
509        // Remove commit metadata
510        self.metadata.remove_commit(&entry.commit_hash);
511
512        // Update stack metadata
513        if let Some(stack_meta) = self.metadata.get_stack_mut(&stack_id) {
514            stack_meta.remove_commit(&entry.commit_hash);
515            // Note: We don't remove the branch as there might be other commits on it
516        }
517
518        self.save_to_disk()?;
519
520        Ok(entry)
521    }
522
523    /// Submit a stack entry for review (mark as submitted)
524    pub fn submit_entry(
525        &mut self,
526        stack_id: &Uuid,
527        entry_id: &Uuid,
528        pull_request_id: String,
529    ) -> Result<()> {
530        let stack = self
531            .stacks
532            .get_mut(stack_id)
533            .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
534
535        let entry_commit_hash = {
536            let entry = stack
537                .get_entry(entry_id)
538                .ok_or_else(|| CascadeError::config(format!("Entry {entry_id} not found")))?;
539            entry.commit_hash.clone()
540        };
541
542        // Update stack entry
543        if !stack.mark_entry_submitted(entry_id, pull_request_id.clone()) {
544            return Err(CascadeError::config(format!(
545                "Failed to mark entry {entry_id} as submitted"
546            )));
547        }
548
549        // Update commit metadata
550        if let Some(commit_meta) = self.metadata.commits.get_mut(&entry_commit_hash) {
551            commit_meta.mark_submitted(pull_request_id);
552        }
553
554        // Update stack metadata statistics
555        if let Some(stack_meta) = self.metadata.get_stack_mut(stack_id) {
556            let submitted_count = stack.entries.iter().filter(|e| e.is_submitted).count();
557            stack_meta.update_stats(
558                stack.entries.len(),
559                submitted_count,
560                stack_meta.merged_commits,
561            );
562        }
563
564        self.save_to_disk()?;
565
566        Ok(())
567    }
568
569    /// Repair data consistency issues in all stacks
570    pub fn repair_all_stacks(&mut self) -> Result<()> {
571        for stack in self.stacks.values_mut() {
572            stack.repair_data_consistency();
573        }
574        self.save_to_disk()?;
575        Ok(())
576    }
577
578    /// Get all stacks
579    pub fn get_all_stacks(&self) -> Vec<&Stack> {
580        self.stacks.values().collect()
581    }
582
583    /// Get stack metadata
584    pub fn get_stack_metadata(&self, stack_id: &Uuid) -> Option<&StackMetadata> {
585        self.metadata.get_stack(stack_id)
586    }
587
588    /// Get repository metadata
589    pub fn get_repository_metadata(&self) -> &RepositoryMetadata {
590        &self.metadata
591    }
592
593    /// Get the Git repository
594    pub fn git_repo(&self) -> &GitRepository {
595        &self.repo
596    }
597
598    /// Get the repository path
599    pub fn repo_path(&self) -> &Path {
600        &self.repo_path
601    }
602
603    // Edit mode management methods
604
605    /// Check if currently in edit mode
606    pub fn is_in_edit_mode(&self) -> bool {
607        self.metadata
608            .edit_mode
609            .as_ref()
610            .map(|edit_state| edit_state.is_active)
611            .unwrap_or(false)
612    }
613
614    /// Get current edit mode information
615    pub fn get_edit_mode_info(&self) -> Option<&super::metadata::EditModeState> {
616        self.metadata.edit_mode.as_ref()
617    }
618
619    /// Enter edit mode for a specific stack entry
620    pub fn enter_edit_mode(&mut self, stack_id: Uuid, entry_id: Uuid) -> Result<()> {
621        // Get the commit hash first to avoid borrow checker issues
622        let commit_hash = {
623            let stack = self
624                .get_stack(&stack_id)
625                .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
626
627            let entry = stack.get_entry(&entry_id).ok_or_else(|| {
628                CascadeError::config(format!("Entry {entry_id} not found in stack"))
629            })?;
630
631            entry.commit_hash.clone()
632        };
633
634        // If already in edit mode, exit the current one first
635        if self.is_in_edit_mode() {
636            self.exit_edit_mode()?;
637        }
638
639        // Create new edit mode state
640        let edit_state = super::metadata::EditModeState::new(stack_id, entry_id, commit_hash);
641
642        self.metadata.edit_mode = Some(edit_state);
643        self.save_to_disk()?;
644
645        debug!(
646            "Entered edit mode for entry {} in stack {}",
647            entry_id, stack_id
648        );
649        Ok(())
650    }
651
652    /// Exit edit mode
653    pub fn exit_edit_mode(&mut self) -> Result<()> {
654        if !self.is_in_edit_mode() {
655            return Err(CascadeError::config("Not currently in edit mode"));
656        }
657
658        // Clear edit mode state
659        self.metadata.edit_mode = None;
660        self.save_to_disk()?;
661
662        debug!("Exited edit mode");
663        Ok(())
664    }
665
666    /// Sync stack with Git repository state
667    pub fn sync_stack(&mut self, stack_id: &Uuid) -> Result<()> {
668        let stack = self
669            .stacks
670            .get_mut(stack_id)
671            .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
672
673        // šŸ†• ENHANCED: Check Git integrity first (branch HEAD matches stored commits)
674        if let Err(integrity_error) = stack.validate_git_integrity(&self.repo) {
675            stack.update_status(StackStatus::Corrupted);
676            return Err(CascadeError::branch(format!(
677                "Stack '{}' Git integrity check failed:\n{}",
678                stack.name, integrity_error
679            )));
680        }
681
682        // Check if all commits still exist
683        let mut missing_commits = Vec::new();
684        for entry in &stack.entries {
685            if !self.repo.commit_exists(&entry.commit_hash)? {
686                missing_commits.push(entry.commit_hash.clone());
687            }
688        }
689
690        if !missing_commits.is_empty() {
691            stack.update_status(StackStatus::Corrupted);
692            return Err(CascadeError::branch(format!(
693                "Stack {} has missing commits: {}",
694                stack.name,
695                missing_commits.join(", ")
696            )));
697        }
698
699        // Check if base branch exists and has new commits (try to fetch from remote if not local)
700        if !self.repo.branch_exists_or_fetch(&stack.base_branch)? {
701            return Err(CascadeError::branch(format!(
702                "Base branch '{}' does not exist locally or remotely. Check the branch name or switch to a different base.",
703                stack.base_branch
704            )));
705        }
706
707        let _base_hash = self.repo.get_branch_head(&stack.base_branch)?;
708
709        // Check if any stack entries are missing their commits
710        let mut corrupted_entry = None;
711        for entry in &stack.entries {
712            if !self.repo.commit_exists(&entry.commit_hash)? {
713                corrupted_entry = Some((entry.commit_hash.clone(), entry.branch.clone()));
714                break;
715            }
716        }
717
718        if let Some((commit_hash, branch)) = corrupted_entry {
719            stack.update_status(StackStatus::Corrupted);
720            return Err(CascadeError::branch(format!(
721                "Commit {commit_hash} from stack entry '{branch}' no longer exists"
722            )));
723        }
724
725        // Compare base branch with the earliest commit in the stack
726        let needs_sync = if let Some(first_entry) = stack.entries.first() {
727            // Get commits between base and first entry
728            match self
729                .repo
730                .get_commits_between(&stack.base_branch, &first_entry.commit_hash)
731            {
732                Ok(commits) => !commits.is_empty(), // If there are commits, we need to sync
733                Err(_) => true,                     // If we can't compare, assume we need to sync
734            }
735        } else {
736            false // Empty stack is always clean
737        };
738
739        // Update stack status based on sync needs
740        if needs_sync {
741            stack.update_status(StackStatus::NeedsSync);
742            debug!(
743                "Stack '{}' needs sync - new commits on base branch",
744                stack.name
745            );
746        } else {
747            stack.update_status(StackStatus::Clean);
748            debug!("Stack '{}' is clean", stack.name);
749        }
750
751        // Update metadata
752        if let Some(stack_meta) = self.metadata.get_stack_mut(stack_id) {
753            stack_meta.set_up_to_date(true);
754        }
755
756        self.save_to_disk()?;
757
758        Ok(())
759    }
760
761    /// List all stacks with their status
762    pub fn list_stacks(&self) -> Vec<(Uuid, &str, &StackStatus, usize, Option<&str>)> {
763        self.stacks
764            .values()
765            .map(|stack| {
766                (
767                    stack.id,
768                    stack.name.as_str(),
769                    &stack.status,
770                    stack.entries.len(),
771                    if stack.is_active {
772                        Some("active")
773                    } else {
774                        None
775                    },
776                )
777            })
778            .collect()
779    }
780
781    /// Get all stacks as Stack objects for TUI
782    pub fn get_all_stacks_objects(&self) -> Result<Vec<Stack>> {
783        let mut stacks: Vec<Stack> = self.stacks.values().cloned().collect();
784        stacks.sort_by(|a, b| a.name.cmp(&b.name));
785        Ok(stacks)
786    }
787
788    /// Validate all stacks including Git integrity
789    pub fn validate_all(&self) -> Result<()> {
790        for stack in self.stacks.values() {
791            // Basic structure validation
792            stack.validate().map_err(|e| {
793                CascadeError::config(format!("Stack '{}' validation failed: {}", stack.name, e))
794            })?;
795
796            // Git integrity validation
797            stack.validate_git_integrity(&self.repo).map_err(|e| {
798                CascadeError::config(format!(
799                    "Stack '{}' Git integrity validation failed: {}",
800                    stack.name, e
801                ))
802            })?;
803        }
804        Ok(())
805    }
806
807    /// Validate a specific stack including Git integrity
808    pub fn validate_stack(&self, stack_id: &Uuid) -> Result<()> {
809        let stack = self
810            .stacks
811            .get(stack_id)
812            .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
813
814        // Basic structure validation
815        stack.validate().map_err(|e| {
816            CascadeError::config(format!("Stack '{}' validation failed: {}", stack.name, e))
817        })?;
818
819        // Git integrity validation
820        stack.validate_git_integrity(&self.repo).map_err(|e| {
821            CascadeError::config(format!(
822                "Stack '{}' Git integrity validation failed: {}",
823                stack.name, e
824            ))
825        })?;
826
827        Ok(())
828    }
829
830    /// Save all data to disk
831    pub fn save_to_disk(&self) -> Result<()> {
832        // Ensure config directory exists
833        if !self.config_dir.exists() {
834            fs::create_dir_all(&self.config_dir).map_err(|e| {
835                CascadeError::config(format!("Failed to create config directory: {e}"))
836            })?;
837        }
838
839        // Save stacks atomically
840        crate::utils::atomic_file::write_json(&self.stacks_file, &self.stacks)?;
841
842        // Save metadata atomically
843        crate::utils::atomic_file::write_json(&self.metadata_file, &self.metadata)?;
844
845        Ok(())
846    }
847
848    /// Load data from disk
849    fn load_from_disk(&mut self) -> Result<()> {
850        // Load stacks if file exists
851        if self.stacks_file.exists() {
852            let stacks_content = fs::read_to_string(&self.stacks_file)
853                .map_err(|e| CascadeError::config(format!("Failed to read stacks file: {e}")))?;
854
855            self.stacks = serde_json::from_str(&stacks_content)
856                .map_err(|e| CascadeError::config(format!("Failed to parse stacks file: {e}")))?;
857        }
858
859        // Load metadata if file exists
860        if self.metadata_file.exists() {
861            let metadata_content = fs::read_to_string(&self.metadata_file)
862                .map_err(|e| CascadeError::config(format!("Failed to read metadata file: {e}")))?;
863
864            self.metadata = serde_json::from_str(&metadata_content)
865                .map_err(|e| CascadeError::config(format!("Failed to parse metadata file: {e}")))?;
866        }
867
868        Ok(())
869    }
870
871    /// Check if the user has changed branches since the stack was activated
872    /// Returns true if branch change detected and user wants to proceed
873    pub fn check_for_branch_change(&mut self) -> Result<bool> {
874        // Extract stack information first to avoid borrow conflicts
875        let (stack_id, stack_name, stored_branch) = {
876            if let Some(active_stack) = self.get_active_stack() {
877                let stack_id = active_stack.id;
878                let stack_name = active_stack.name.clone();
879                let stored_branch = if let Some(stack_meta) = self.metadata.get_stack(&stack_id) {
880                    stack_meta.current_branch.clone()
881                } else {
882                    None
883                };
884                (Some(stack_id), stack_name, stored_branch)
885            } else {
886                (None, String::new(), None)
887            }
888        };
889
890        // If no active stack, nothing to check
891        let Some(stack_id) = stack_id else {
892            return Ok(true);
893        };
894
895        let current_branch = self.repo.get_current_branch().ok();
896
897        // Check if branch has changed
898        if stored_branch.as_ref() != current_branch.as_ref() {
899            Output::warning("Branch change detected!");
900            Output::sub_item(format!(
901                "Stack '{}' was active on: {}",
902                stack_name,
903                stored_branch.as_deref().unwrap_or("unknown")
904            ));
905            Output::sub_item(format!(
906                "Current branch: {}",
907                current_branch.as_deref().unwrap_or("unknown")
908            ));
909            Output::spacing();
910
911            let options = vec![
912                format!("Keep stack '{stack_name}' active (continue with stack workflow)"),
913                "Deactivate stack (use normal Git workflow)".to_string(),
914                "Switch to a different stack".to_string(),
915                "Cancel and stay on current workflow".to_string(),
916            ];
917
918            let choice = Select::with_theme(&ColorfulTheme::default())
919                .with_prompt("What would you like to do?")
920                .default(0)
921                .items(&options)
922                .interact()
923                .map_err(|e| CascadeError::config(format!("Failed to get user choice: {e}")))?;
924
925            match choice {
926                0 => {
927                    // Update the tracked branch and continue
928                    if let Some(stack_meta) = self.metadata.get_stack_mut(&stack_id) {
929                        stack_meta.set_current_branch(current_branch);
930                    }
931                    self.save_to_disk()?;
932                    Output::success(format!(
933                        "Continuing with stack '{stack_name}' on current branch"
934                    ));
935                    return Ok(true);
936                }
937                1 => {
938                    // Deactivate the stack
939                    self.set_active_stack(None)?;
940                    Output::success(format!(
941                        "Deactivated stack '{stack_name}' - using normal Git workflow"
942                    ));
943                    return Ok(false);
944                }
945                2 => {
946                    // Show available stacks
947                    let stacks = self.get_all_stacks();
948                    if stacks.len() <= 1 {
949                        Output::warning("No other stacks available. Deactivating current stack.");
950                        self.set_active_stack(None)?;
951                        return Ok(false);
952                    }
953
954                    Output::spacing();
955                    Output::info("Available stacks:");
956                    for (i, stack) in stacks.iter().enumerate() {
957                        if stack.id != stack_id {
958                            Output::numbered_item(i + 1, &stack.name);
959                        }
960                    }
961                    let stack_name_input: String = Input::with_theme(&ColorfulTheme::default())
962                        .with_prompt("Enter stack name")
963                        .validate_with(|input: &String| -> std::result::Result<(), &str> {
964                            if input.trim().is_empty() {
965                                Err("Stack name cannot be empty")
966                            } else {
967                                Ok(())
968                            }
969                        })
970                        .interact_text()
971                        .map_err(|e| {
972                            CascadeError::config(format!("Failed to get user input: {e}"))
973                        })?;
974                    let stack_name_input = stack_name_input.trim();
975
976                    if let Err(e) = self.set_active_stack_by_name(stack_name_input) {
977                        Output::warning(format!("{e}"));
978                        Output::sub_item("Deactivating stack instead.");
979                        self.set_active_stack(None)?;
980                        return Ok(false);
981                    } else {
982                        Output::success(format!("Switched to stack '{stack_name_input}'"));
983                        return Ok(true);
984                    }
985                }
986                3 => {
987                    Output::info("Cancelled - no changes made");
988                    return Ok(false);
989                }
990                _ => {
991                    Output::info("Invalid choice - no changes made");
992                    return Ok(false);
993                }
994            }
995        }
996
997        // No branch change detected
998        Ok(true)
999    }
1000
1001    /// Handle Git integrity issues with multiple user-friendly options
1002    /// Provides non-destructive choices for dealing with branch modifications
1003    pub fn handle_branch_modifications(
1004        &mut self,
1005        stack_id: &Uuid,
1006        auto_mode: Option<String>,
1007    ) -> Result<()> {
1008        let stack = self
1009            .stacks
1010            .get_mut(stack_id)
1011            .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
1012
1013        debug!("Checking Git integrity for stack '{}'", stack.name);
1014
1015        // Detect all modifications
1016        let mut modifications = Vec::new();
1017        for entry in &stack.entries {
1018            if !self.repo.branch_exists(&entry.branch) {
1019                modifications.push(BranchModification::Missing {
1020                    branch: entry.branch.clone(),
1021                    entry_id: entry.id,
1022                    expected_commit: entry.commit_hash.clone(),
1023                });
1024            } else if let Ok(branch_head) = self.repo.get_branch_head(&entry.branch) {
1025                if branch_head != entry.commit_hash {
1026                    // Get extra commits and their messages
1027                    let extra_commits = self
1028                        .repo
1029                        .get_commits_between(&entry.commit_hash, &branch_head)?;
1030                    let mut extra_messages = Vec::new();
1031                    for commit in &extra_commits {
1032                        if let Some(message) = commit.message() {
1033                            let first_line =
1034                                message.lines().next().unwrap_or("(no message)").to_string();
1035                            extra_messages.push(format!(
1036                                "{}: {}",
1037                                &commit.id().to_string()[..8],
1038                                first_line
1039                            ));
1040                        }
1041                    }
1042
1043                    modifications.push(BranchModification::ExtraCommits {
1044                        branch: entry.branch.clone(),
1045                        entry_id: entry.id,
1046                        expected_commit: entry.commit_hash.clone(),
1047                        actual_commit: branch_head,
1048                        extra_commit_count: extra_commits.len(),
1049                        extra_commit_messages: extra_messages,
1050                    });
1051                }
1052            }
1053        }
1054
1055        if modifications.is_empty() {
1056            // Silent success - no issues to report
1057            return Ok(());
1058        }
1059
1060        // Show detected modifications
1061        println!();
1062        Output::section(format!("Branch modifications detected in '{}'", stack.name));
1063        for (i, modification) in modifications.iter().enumerate() {
1064            match modification {
1065                BranchModification::Missing { branch, .. } => {
1066                    Output::numbered_item(i + 1, format!("Branch '{branch}' is missing"));
1067                }
1068                BranchModification::ExtraCommits {
1069                    branch,
1070                    expected_commit,
1071                    actual_commit,
1072                    extra_commit_count,
1073                    extra_commit_messages,
1074                    ..
1075                } => {
1076                    println!(
1077                        "   {}. Branch '{}' has {} extra commit(s)",
1078                        i + 1,
1079                        branch,
1080                        extra_commit_count
1081                    );
1082                    println!(
1083                        "      Expected: {} | Actual: {}",
1084                        &expected_commit[..8],
1085                        &actual_commit[..8]
1086                    );
1087
1088                    // Show extra commit messages (first few only)
1089                    for (j, message) in extra_commit_messages.iter().enumerate() {
1090                        match j.cmp(&3) {
1091                            std::cmp::Ordering::Less => {
1092                                Output::sub_item(format!("     + {message}"));
1093                            }
1094                            std::cmp::Ordering::Equal => {
1095                                Output::sub_item(format!(
1096                                    "     + ... and {} more",
1097                                    extra_commit_count - 3
1098                                ));
1099                                break;
1100                            }
1101                            std::cmp::Ordering::Greater => {
1102                                break;
1103                            }
1104                        }
1105                    }
1106                }
1107            }
1108        }
1109        Output::spacing();
1110
1111        // Auto mode handling
1112        if let Some(mode) = auto_mode {
1113            return self.apply_auto_fix(stack_id, &modifications, &mode);
1114        }
1115
1116        // Interactive mode - ask user for each modification
1117        let mut handled_count = 0;
1118        let mut skipped_count = 0;
1119        for modification in modifications.iter() {
1120            let was_skipped = self.handle_single_modification(stack_id, modification)?;
1121            if was_skipped {
1122                skipped_count += 1;
1123            } else {
1124                handled_count += 1;
1125            }
1126        }
1127
1128        self.save_to_disk()?;
1129
1130        // Show appropriate summary based on what was done
1131        if skipped_count == 0 {
1132            Output::success("All branch modifications resolved");
1133        } else if handled_count > 0 {
1134            Output::warning(format!(
1135                "Resolved {} modification(s), {} skipped",
1136                handled_count, skipped_count
1137            ));
1138        } else {
1139            Output::warning("All modifications skipped - integrity issues remain");
1140        }
1141
1142        Ok(())
1143    }
1144
1145    /// Handle a single branch modification interactively
1146    /// Returns true if the modification was skipped, false if handled
1147    fn handle_single_modification(
1148        &mut self,
1149        stack_id: &Uuid,
1150        modification: &BranchModification,
1151    ) -> Result<bool> {
1152        match modification {
1153            BranchModification::Missing {
1154                branch,
1155                expected_commit,
1156                ..
1157            } => {
1158                Output::info(format!("Missing branch '{branch}'"));
1159                Output::sub_item(format!(
1160                    "Will create the branch at commit {}",
1161                    &expected_commit[..8]
1162                ));
1163
1164                self.repo.create_branch(branch, Some(expected_commit))?;
1165                Output::success(format!("Created branch '{branch}'"));
1166                Ok(false) // Not skipped
1167            }
1168
1169            BranchModification::ExtraCommits {
1170                branch,
1171                entry_id,
1172                expected_commit,
1173                extra_commit_count,
1174                ..
1175            } => {
1176                println!();
1177                Output::info(format!(
1178                    "Branch '{}' has {} extra commit(s)",
1179                    branch, extra_commit_count
1180                ));
1181                let options = vec![
1182                    "Incorporate - Update stack entry to include extra commits",
1183                    "Split - Create new stack entry for extra commits",
1184                    "Reset - Remove extra commits (DESTRUCTIVE)",
1185                    "Skip - Leave as-is for now",
1186                ];
1187
1188                let choice = Select::with_theme(&ColorfulTheme::default())
1189                    .with_prompt("Choose how to handle extra commits")
1190                    .default(0)
1191                    .items(&options)
1192                    .interact()
1193                    .map_err(|e| CascadeError::config(format!("Failed to get user choice: {e}")))?;
1194
1195                match choice {
1196                    0 => {
1197                        self.incorporate_extra_commits(stack_id, *entry_id, branch)?;
1198                        Ok(false) // Not skipped
1199                    }
1200                    1 => {
1201                        self.split_extra_commits(stack_id, *entry_id, branch)?;
1202                        Ok(false) // Not skipped
1203                    }
1204                    2 => {
1205                        self.reset_branch_destructive(branch, expected_commit)?;
1206                        Ok(false) // Not skipped
1207                    }
1208                    3 => {
1209                        Output::warning(format!("Skipped '{branch}' - integrity issue remains"));
1210                        Ok(true) // Skipped
1211                    }
1212                    _ => {
1213                        Output::warning(format!("Invalid choice - skipped '{branch}'"));
1214                        Ok(true) // Skipped
1215                    }
1216                }
1217            }
1218        }
1219    }
1220
1221    /// Apply automatic fix based on mode
1222    fn apply_auto_fix(
1223        &mut self,
1224        stack_id: &Uuid,
1225        modifications: &[BranchModification],
1226        mode: &str,
1227    ) -> Result<()> {
1228        Output::info(format!("šŸ¤– Applying automatic fix mode: {mode}"));
1229
1230        for modification in modifications {
1231            match (modification, mode) {
1232                (
1233                    BranchModification::Missing {
1234                        branch,
1235                        expected_commit,
1236                        ..
1237                    },
1238                    _,
1239                ) => {
1240                    self.repo.create_branch(branch, Some(expected_commit))?;
1241                    Output::success(format!("Created missing branch '{branch}'"));
1242                }
1243
1244                (
1245                    BranchModification::ExtraCommits {
1246                        branch, entry_id, ..
1247                    },
1248                    "incorporate",
1249                ) => {
1250                    self.incorporate_extra_commits(stack_id, *entry_id, branch)?;
1251                }
1252
1253                (
1254                    BranchModification::ExtraCommits {
1255                        branch, entry_id, ..
1256                    },
1257                    "split",
1258                ) => {
1259                    self.split_extra_commits(stack_id, *entry_id, branch)?;
1260                }
1261
1262                (
1263                    BranchModification::ExtraCommits {
1264                        branch,
1265                        expected_commit,
1266                        ..
1267                    },
1268                    "reset",
1269                ) => {
1270                    self.reset_branch_destructive(branch, expected_commit)?;
1271                }
1272
1273                _ => {
1274                    return Err(CascadeError::config(format!(
1275                        "Unknown auto-fix mode '{mode}'. Use: incorporate, split, reset"
1276                    )));
1277                }
1278            }
1279        }
1280
1281        self.save_to_disk()?;
1282        Output::success(format!("Auto-fix completed for mode: {mode}"));
1283        Ok(())
1284    }
1285
1286    /// Incorporate extra commits into the existing stack entry (update commit hash)
1287    fn incorporate_extra_commits(
1288        &mut self,
1289        stack_id: &Uuid,
1290        entry_id: Uuid,
1291        branch: &str,
1292    ) -> Result<()> {
1293        let stack = self
1294            .stacks
1295            .get_mut(stack_id)
1296            .ok_or_else(|| CascadeError::config(format!("Stack not found: {}", stack_id)))?;
1297
1298        if let Some(entry) = stack.entries.iter_mut().find(|e| e.id == entry_id) {
1299            let new_head = self.repo.get_branch_head(branch)?;
1300            let old_commit = entry.commit_hash[..8].to_string(); // Clone to avoid borrowing issue
1301
1302            // Get the extra commits for message update
1303            let extra_commits = self
1304                .repo
1305                .get_commits_between(&entry.commit_hash, &new_head)?;
1306
1307            // Update the entry to point to the new HEAD
1308            // Note: We intentionally do NOT append commit messages here
1309            // The entry's message should describe the feature/change, not list all commits
1310            entry.commit_hash = new_head.clone();
1311
1312            Output::success(format!(
1313                "Incorporated {} commit(s) into entry '{}'",
1314                extra_commits.len(),
1315                entry.short_hash()
1316            ));
1317            Output::sub_item(format!("Updated: {} -> {}", old_commit, &new_head[..8]));
1318        }
1319
1320        Ok(())
1321    }
1322
1323    /// Split extra commits into a new stack entry
1324    fn split_extra_commits(&mut self, stack_id: &Uuid, entry_id: Uuid, branch: &str) -> Result<()> {
1325        let stack = self
1326            .stacks
1327            .get_mut(stack_id)
1328            .ok_or_else(|| CascadeError::config(format!("Stack not found: {}", stack_id)))?;
1329        let new_head = self.repo.get_branch_head(branch)?;
1330
1331        // Find the position of the current entry
1332        let entry_position = stack
1333            .entries
1334            .iter()
1335            .position(|e| e.id == entry_id)
1336            .ok_or_else(|| CascadeError::config("Entry not found in stack"))?;
1337
1338        // Create a new branch name for the split
1339        let base_name = branch.trim_end_matches(|c: char| c.is_ascii_digit() || c == '-');
1340        let new_branch = format!("{base_name}-continued");
1341
1342        // Create new branch at the current HEAD
1343        self.repo.create_branch(&new_branch, Some(&new_head))?;
1344
1345        // Get extra commits for message creation
1346        let original_entry = &stack.entries[entry_position];
1347        let original_commit_hash = original_entry.commit_hash.clone(); // Clone to avoid borrowing issue
1348        let extra_commits = self
1349            .repo
1350            .get_commits_between(&original_commit_hash, &new_head)?;
1351
1352        // Create commit message from extra commits
1353        let mut extra_messages = Vec::new();
1354        for commit in &extra_commits {
1355            if let Some(message) = commit.message() {
1356                let first_line = message.lines().next().unwrap_or("").to_string();
1357                extra_messages.push(first_line);
1358            }
1359        }
1360
1361        let new_message = if extra_messages.len() == 1 {
1362            extra_messages[0].clone()
1363        } else {
1364            format!("Combined changes:\n• {}", extra_messages.join("\n• "))
1365        };
1366
1367        // Create new stack entry manually (no constructor method exists)
1368        let now = chrono::Utc::now();
1369        let new_entry = crate::stack::StackEntry {
1370            id: uuid::Uuid::new_v4(),
1371            branch: new_branch.clone(),
1372            commit_hash: new_head,
1373            message: new_message,
1374            parent_id: Some(entry_id), // Parent is the current entry
1375            children: Vec::new(),
1376            created_at: now,
1377            updated_at: now,
1378            is_submitted: false,
1379            pull_request_id: None,
1380            is_synced: false,
1381        };
1382
1383        // Insert the new entry after the current one
1384        stack.entries.insert(entry_position + 1, new_entry);
1385
1386        // Reset the original branch to its expected commit
1387        self.repo
1388            .reset_branch_to_commit(branch, &original_commit_hash)?;
1389
1390        println!(
1391            "   āœ… Split {} commit(s) into new entry '{}'",
1392            extra_commits.len(),
1393            new_branch
1394        );
1395        println!("      Original branch '{branch}' reset to expected commit");
1396
1397        Ok(())
1398    }
1399
1400    /// Reset branch to expected commit (destructive - loses extra work)
1401    fn reset_branch_destructive(&self, branch: &str, expected_commit: &str) -> Result<()> {
1402        self.repo.reset_branch_to_commit(branch, expected_commit)?;
1403        Output::warning(format!(
1404            "Reset branch '{}' to {} (extra commits lost)",
1405            branch,
1406            &expected_commit[..8]
1407        ));
1408        Ok(())
1409    }
1410}
1411
1412#[cfg(test)]
1413mod tests {
1414    use super::*;
1415    use std::process::Command;
1416    use tempfile::TempDir;
1417
1418    fn create_test_repo() -> (TempDir, PathBuf) {
1419        let temp_dir = TempDir::new().unwrap();
1420        let repo_path = temp_dir.path().to_path_buf();
1421
1422        // Initialize git repository
1423        Command::new("git")
1424            .args(["init"])
1425            .current_dir(&repo_path)
1426            .output()
1427            .unwrap();
1428
1429        // Configure git
1430        Command::new("git")
1431            .args(["config", "user.name", "Test User"])
1432            .current_dir(&repo_path)
1433            .output()
1434            .unwrap();
1435
1436        Command::new("git")
1437            .args(["config", "user.email", "test@example.com"])
1438            .current_dir(&repo_path)
1439            .output()
1440            .unwrap();
1441
1442        // Create an initial commit
1443        std::fs::write(repo_path.join("README.md"), "# Test Repo").unwrap();
1444        Command::new("git")
1445            .args(["add", "."])
1446            .current_dir(&repo_path)
1447            .output()
1448            .unwrap();
1449
1450        Command::new("git")
1451            .args(["commit", "-m", "Initial commit"])
1452            .current_dir(&repo_path)
1453            .output()
1454            .unwrap();
1455
1456        // Initialize cascade
1457        crate::config::initialize_repo(&repo_path, Some("https://test.bitbucket.com".to_string()))
1458            .unwrap();
1459
1460        (temp_dir, repo_path)
1461    }
1462
1463    #[test]
1464    fn test_create_stack_manager() {
1465        let (_temp_dir, repo_path) = create_test_repo();
1466        let manager = StackManager::new(&repo_path).unwrap();
1467
1468        assert_eq!(manager.stacks.len(), 0);
1469        assert!(manager.get_active_stack().is_none());
1470    }
1471
1472    #[test]
1473    fn test_create_and_manage_stack() {
1474        let (_temp_dir, repo_path) = create_test_repo();
1475        let mut manager = StackManager::new(&repo_path).unwrap();
1476
1477        // Create a stack using the default branch
1478        let stack_id = manager
1479            .create_stack(
1480                "test-stack".to_string(),
1481                None, // Use default branch
1482                Some("Test stack description".to_string()),
1483            )
1484            .unwrap();
1485
1486        // Verify stack was created
1487        assert_eq!(manager.stacks.len(), 1);
1488        let stack = manager.get_stack(&stack_id).unwrap();
1489        assert_eq!(stack.name, "test-stack");
1490        // Should use the default branch (which gets set from the Git repo)
1491        assert!(!stack.base_branch.is_empty());
1492        assert!(stack.is_active);
1493
1494        // Verify it's the active stack
1495        let active = manager.get_active_stack().unwrap();
1496        assert_eq!(active.id, stack_id);
1497
1498        // Test get by name
1499        let found = manager.get_stack_by_name("test-stack").unwrap();
1500        assert_eq!(found.id, stack_id);
1501    }
1502
1503    #[test]
1504    fn test_stack_persistence() {
1505        let (_temp_dir, repo_path) = create_test_repo();
1506
1507        let stack_id = {
1508            let mut manager = StackManager::new(&repo_path).unwrap();
1509            manager
1510                .create_stack("persistent-stack".to_string(), None, None)
1511                .unwrap()
1512        };
1513
1514        // Create new manager and verify data was loaded
1515        let manager = StackManager::new(&repo_path).unwrap();
1516        assert_eq!(manager.stacks.len(), 1);
1517        let stack = manager.get_stack(&stack_id).unwrap();
1518        assert_eq!(stack.name, "persistent-stack");
1519    }
1520
1521    #[test]
1522    fn test_multiple_stacks() {
1523        let (_temp_dir, repo_path) = create_test_repo();
1524        let mut manager = StackManager::new(&repo_path).unwrap();
1525
1526        let stack1_id = manager
1527            .create_stack("stack-1".to_string(), None, None)
1528            .unwrap();
1529        let stack2_id = manager
1530            .create_stack("stack-2".to_string(), None, None)
1531            .unwrap();
1532
1533        assert_eq!(manager.stacks.len(), 2);
1534
1535        // Second stack should be active (newly created stacks become active)
1536        assert!(!manager.get_stack(&stack1_id).unwrap().is_active);
1537        assert!(manager.get_stack(&stack2_id).unwrap().is_active);
1538
1539        // Change active stack
1540        manager.set_active_stack(Some(stack2_id)).unwrap();
1541        assert!(!manager.get_stack(&stack1_id).unwrap().is_active);
1542        assert!(manager.get_stack(&stack2_id).unwrap().is_active);
1543    }
1544
1545    #[test]
1546    fn test_delete_stack() {
1547        let (_temp_dir, repo_path) = create_test_repo();
1548        let mut manager = StackManager::new(&repo_path).unwrap();
1549
1550        let stack_id = manager
1551            .create_stack("to-delete".to_string(), None, None)
1552            .unwrap();
1553        assert_eq!(manager.stacks.len(), 1);
1554
1555        let deleted = manager.delete_stack(&stack_id).unwrap();
1556        assert_eq!(deleted.name, "to-delete");
1557        assert_eq!(manager.stacks.len(), 0);
1558        assert!(manager.get_active_stack().is_none());
1559    }
1560
1561    #[test]
1562    fn test_validation() {
1563        let (_temp_dir, repo_path) = create_test_repo();
1564        let mut manager = StackManager::new(&repo_path).unwrap();
1565
1566        manager
1567            .create_stack("valid-stack".to_string(), None, None)
1568            .unwrap();
1569
1570        // Should pass validation
1571        assert!(manager.validate_all().is_ok());
1572    }
1573
1574    #[test]
1575    fn test_duplicate_commit_message_detection() {
1576        let (_temp_dir, repo_path) = create_test_repo();
1577        let mut manager = StackManager::new(&repo_path).unwrap();
1578
1579        // Create a stack
1580        manager
1581            .create_stack("test-stack".to_string(), None, None)
1582            .unwrap();
1583
1584        // Create first commit
1585        std::fs::write(repo_path.join("file1.txt"), "content1").unwrap();
1586        Command::new("git")
1587            .args(["add", "file1.txt"])
1588            .current_dir(&repo_path)
1589            .output()
1590            .unwrap();
1591
1592        Command::new("git")
1593            .args(["commit", "-m", "Add authentication feature"])
1594            .current_dir(&repo_path)
1595            .output()
1596            .unwrap();
1597
1598        let commit1_hash = Command::new("git")
1599            .args(["rev-parse", "HEAD"])
1600            .current_dir(&repo_path)
1601            .output()
1602            .unwrap();
1603        let commit1_hash = String::from_utf8_lossy(&commit1_hash.stdout)
1604            .trim()
1605            .to_string();
1606
1607        // Push first commit to stack - should succeed
1608        let entry1_id = manager
1609            .push_to_stack(
1610                "feature/auth".to_string(),
1611                commit1_hash,
1612                "Add authentication feature".to_string(),
1613                "main".to_string(),
1614            )
1615            .unwrap();
1616
1617        // Verify first entry was added
1618        assert!(manager
1619            .get_active_stack()
1620            .unwrap()
1621            .get_entry(&entry1_id)
1622            .is_some());
1623
1624        // Create second commit
1625        std::fs::write(repo_path.join("file2.txt"), "content2").unwrap();
1626        Command::new("git")
1627            .args(["add", "file2.txt"])
1628            .current_dir(&repo_path)
1629            .output()
1630            .unwrap();
1631
1632        Command::new("git")
1633            .args(["commit", "-m", "Different commit message"])
1634            .current_dir(&repo_path)
1635            .output()
1636            .unwrap();
1637
1638        let commit2_hash = Command::new("git")
1639            .args(["rev-parse", "HEAD"])
1640            .current_dir(&repo_path)
1641            .output()
1642            .unwrap();
1643        let commit2_hash = String::from_utf8_lossy(&commit2_hash.stdout)
1644            .trim()
1645            .to_string();
1646
1647        // Try to push second commit with the SAME message - should fail
1648        let result = manager.push_to_stack(
1649            "feature/auth2".to_string(),
1650            commit2_hash.clone(),
1651            "Add authentication feature".to_string(), // Same message as first commit
1652            "main".to_string(),
1653        );
1654
1655        // Should fail with validation error
1656        assert!(result.is_err());
1657        let error = result.unwrap_err();
1658        assert!(matches!(error, CascadeError::Validation(_)));
1659
1660        // Error message should contain helpful information
1661        let error_msg = error.to_string();
1662        assert!(error_msg.contains("Duplicate commit message"));
1663        assert!(error_msg.contains("Add authentication feature"));
1664        assert!(error_msg.contains("šŸ’” Consider using a more specific message"));
1665
1666        // Push with different message - should succeed
1667        let entry2_id = manager
1668            .push_to_stack(
1669                "feature/auth2".to_string(),
1670                commit2_hash,
1671                "Add authentication validation".to_string(), // Different message
1672                "main".to_string(),
1673            )
1674            .unwrap();
1675
1676        // Verify both entries exist
1677        let stack = manager.get_active_stack().unwrap();
1678        assert_eq!(stack.entries.len(), 2);
1679        assert!(stack.get_entry(&entry1_id).is_some());
1680        assert!(stack.get_entry(&entry2_id).is_some());
1681    }
1682
1683    #[test]
1684    fn test_duplicate_message_with_different_case() {
1685        let (_temp_dir, repo_path) = create_test_repo();
1686        let mut manager = StackManager::new(&repo_path).unwrap();
1687
1688        manager
1689            .create_stack("test-stack".to_string(), None, None)
1690            .unwrap();
1691
1692        // Create and push first commit
1693        std::fs::write(repo_path.join("file1.txt"), "content1").unwrap();
1694        Command::new("git")
1695            .args(["add", "file1.txt"])
1696            .current_dir(&repo_path)
1697            .output()
1698            .unwrap();
1699
1700        Command::new("git")
1701            .args(["commit", "-m", "fix bug"])
1702            .current_dir(&repo_path)
1703            .output()
1704            .unwrap();
1705
1706        let commit1_hash = Command::new("git")
1707            .args(["rev-parse", "HEAD"])
1708            .current_dir(&repo_path)
1709            .output()
1710            .unwrap();
1711        let commit1_hash = String::from_utf8_lossy(&commit1_hash.stdout)
1712            .trim()
1713            .to_string();
1714
1715        manager
1716            .push_to_stack(
1717                "feature/fix1".to_string(),
1718                commit1_hash,
1719                "fix bug".to_string(),
1720                "main".to_string(),
1721            )
1722            .unwrap();
1723
1724        // Create second commit
1725        std::fs::write(repo_path.join("file2.txt"), "content2").unwrap();
1726        Command::new("git")
1727            .args(["add", "file2.txt"])
1728            .current_dir(&repo_path)
1729            .output()
1730            .unwrap();
1731
1732        Command::new("git")
1733            .args(["commit", "-m", "Fix Bug"])
1734            .current_dir(&repo_path)
1735            .output()
1736            .unwrap();
1737
1738        let commit2_hash = Command::new("git")
1739            .args(["rev-parse", "HEAD"])
1740            .current_dir(&repo_path)
1741            .output()
1742            .unwrap();
1743        let commit2_hash = String::from_utf8_lossy(&commit2_hash.stdout)
1744            .trim()
1745            .to_string();
1746
1747        // Different case should be allowed (case-sensitive comparison)
1748        let result = manager.push_to_stack(
1749            "feature/fix2".to_string(),
1750            commit2_hash,
1751            "Fix Bug".to_string(), // Different case
1752            "main".to_string(),
1753        );
1754
1755        // Should succeed because it's case-sensitive
1756        assert!(result.is_ok());
1757    }
1758
1759    #[test]
1760    fn test_duplicate_message_across_different_stacks() {
1761        let (_temp_dir, repo_path) = create_test_repo();
1762        let mut manager = StackManager::new(&repo_path).unwrap();
1763
1764        // Create first stack and push commit
1765        let stack1_id = manager
1766            .create_stack("stack1".to_string(), None, None)
1767            .unwrap();
1768
1769        std::fs::write(repo_path.join("file1.txt"), "content1").unwrap();
1770        Command::new("git")
1771            .args(["add", "file1.txt"])
1772            .current_dir(&repo_path)
1773            .output()
1774            .unwrap();
1775
1776        Command::new("git")
1777            .args(["commit", "-m", "shared message"])
1778            .current_dir(&repo_path)
1779            .output()
1780            .unwrap();
1781
1782        let commit1_hash = Command::new("git")
1783            .args(["rev-parse", "HEAD"])
1784            .current_dir(&repo_path)
1785            .output()
1786            .unwrap();
1787        let commit1_hash = String::from_utf8_lossy(&commit1_hash.stdout)
1788            .trim()
1789            .to_string();
1790
1791        manager
1792            .push_to_stack(
1793                "feature/shared1".to_string(),
1794                commit1_hash,
1795                "shared message".to_string(),
1796                "main".to_string(),
1797            )
1798            .unwrap();
1799
1800        // Create second stack
1801        let stack2_id = manager
1802            .create_stack("stack2".to_string(), None, None)
1803            .unwrap();
1804
1805        // Set second stack as active
1806        manager.set_active_stack(Some(stack2_id)).unwrap();
1807
1808        // Create commit for second stack
1809        std::fs::write(repo_path.join("file2.txt"), "content2").unwrap();
1810        Command::new("git")
1811            .args(["add", "file2.txt"])
1812            .current_dir(&repo_path)
1813            .output()
1814            .unwrap();
1815
1816        Command::new("git")
1817            .args(["commit", "-m", "shared message"])
1818            .current_dir(&repo_path)
1819            .output()
1820            .unwrap();
1821
1822        let commit2_hash = Command::new("git")
1823            .args(["rev-parse", "HEAD"])
1824            .current_dir(&repo_path)
1825            .output()
1826            .unwrap();
1827        let commit2_hash = String::from_utf8_lossy(&commit2_hash.stdout)
1828            .trim()
1829            .to_string();
1830
1831        // Same message in different stack should be allowed
1832        let result = manager.push_to_stack(
1833            "feature/shared2".to_string(),
1834            commit2_hash,
1835            "shared message".to_string(), // Same message but different stack
1836            "main".to_string(),
1837        );
1838
1839        // Should succeed because it's a different stack
1840        assert!(result.is_ok());
1841
1842        // Verify both stacks have entries with the same message
1843        let stack1 = manager.get_stack(&stack1_id).unwrap();
1844        let stack2 = manager.get_stack(&stack2_id).unwrap();
1845
1846        assert_eq!(stack1.entries.len(), 1);
1847        assert_eq!(stack2.entries.len(), 1);
1848        assert_eq!(stack1.entries[0].message, "shared message");
1849        assert_eq!(stack2.entries[0].message, "shared message");
1850    }
1851
1852    #[test]
1853    fn test_duplicate_after_pop() {
1854        let (_temp_dir, repo_path) = create_test_repo();
1855        let mut manager = StackManager::new(&repo_path).unwrap();
1856
1857        manager
1858            .create_stack("test-stack".to_string(), None, None)
1859            .unwrap();
1860
1861        // Create and push first commit
1862        std::fs::write(repo_path.join("file1.txt"), "content1").unwrap();
1863        Command::new("git")
1864            .args(["add", "file1.txt"])
1865            .current_dir(&repo_path)
1866            .output()
1867            .unwrap();
1868
1869        Command::new("git")
1870            .args(["commit", "-m", "temporary message"])
1871            .current_dir(&repo_path)
1872            .output()
1873            .unwrap();
1874
1875        let commit1_hash = Command::new("git")
1876            .args(["rev-parse", "HEAD"])
1877            .current_dir(&repo_path)
1878            .output()
1879            .unwrap();
1880        let commit1_hash = String::from_utf8_lossy(&commit1_hash.stdout)
1881            .trim()
1882            .to_string();
1883
1884        manager
1885            .push_to_stack(
1886                "feature/temp".to_string(),
1887                commit1_hash,
1888                "temporary message".to_string(),
1889                "main".to_string(),
1890            )
1891            .unwrap();
1892
1893        // Pop the entry
1894        let popped = manager.pop_from_stack().unwrap();
1895        assert_eq!(popped.message, "temporary message");
1896
1897        // Create new commit
1898        std::fs::write(repo_path.join("file2.txt"), "content2").unwrap();
1899        Command::new("git")
1900            .args(["add", "file2.txt"])
1901            .current_dir(&repo_path)
1902            .output()
1903            .unwrap();
1904
1905        Command::new("git")
1906            .args(["commit", "-m", "temporary message"])
1907            .current_dir(&repo_path)
1908            .output()
1909            .unwrap();
1910
1911        let commit2_hash = Command::new("git")
1912            .args(["rev-parse", "HEAD"])
1913            .current_dir(&repo_path)
1914            .output()
1915            .unwrap();
1916        let commit2_hash = String::from_utf8_lossy(&commit2_hash.stdout)
1917            .trim()
1918            .to_string();
1919
1920        // Should be able to push same message again after popping
1921        let result = manager.push_to_stack(
1922            "feature/temp2".to_string(),
1923            commit2_hash,
1924            "temporary message".to_string(),
1925            "main".to_string(),
1926        );
1927
1928        assert!(result.is_ok());
1929    }
1930}