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