cascade_cli/stack/
manager.rs

1use super::metadata::RepositoryMetadata;
2use super::{CommitMetadata, Stack, StackEntry, StackMetadata, StackStatus};
3use crate::config::get_repo_config_dir;
4use crate::errors::{CascadeError, Result};
5use crate::git::GitRepository;
6use std::collections::HashMap;
7use std::fs;
8use std::path::{Path, PathBuf};
9use tracing::info;
10use uuid::Uuid;
11
12/// Manages all stack operations and persistence
13pub struct StackManager {
14    /// Git repository interface
15    repo: GitRepository,
16    /// Path to the repository root
17    repo_path: PathBuf,
18    /// Path to cascade config directory
19    config_dir: PathBuf,
20    /// Path to stacks data file
21    stacks_file: PathBuf,
22    /// Path to metadata file
23    metadata_file: PathBuf,
24    /// In-memory stack data
25    stacks: HashMap<Uuid, Stack>,
26    /// Repository metadata
27    metadata: RepositoryMetadata,
28}
29
30impl StackManager {
31    /// Create a new StackManager for the given repository
32    pub fn new(repo_path: &Path) -> Result<Self> {
33        let repo = GitRepository::open(repo_path)?;
34        let config_dir = get_repo_config_dir(repo_path)?;
35        let stacks_file = config_dir.join("stacks.json");
36        let metadata_file = config_dir.join("metadata.json");
37
38        // Determine default base branch - try current branch first, then check for common defaults
39        let default_base = match repo.get_current_branch() {
40            Ok(branch) => branch,
41            Err(_) => {
42                // Fallback: check if common default branches exist
43                if repo.branch_exists("main") {
44                    "main".to_string()
45                } else if repo.branch_exists("master") {
46                    "master".to_string()
47                } else {
48                    // Final fallback to main (modern Git default)
49                    "main".to_string()
50                }
51            }
52        };
53
54        let mut manager = Self {
55            repo,
56            repo_path: repo_path.to_path_buf(),
57            config_dir,
58            stacks_file,
59            metadata_file,
60            stacks: HashMap::new(),
61            metadata: RepositoryMetadata::new(default_base),
62        };
63
64        // Load existing data if available
65        manager.load_from_disk()?;
66
67        Ok(manager)
68    }
69
70    /// Create a new stack
71    pub fn create_stack(
72        &mut self,
73        name: String,
74        base_branch: Option<String>,
75        description: Option<String>,
76    ) -> Result<Uuid> {
77        // Check if stack with this name already exists
78        if self.metadata.find_stack_by_name(&name).is_some() {
79            return Err(CascadeError::config(format!(
80                "Stack '{name}' already exists"
81            )));
82        }
83
84        // Use provided base branch or default
85        let base_branch = base_branch.unwrap_or_else(|| self.metadata.default_base_branch.clone());
86
87        // Verify base branch exists (try to fetch from remote if not local)
88        if !self.repo.branch_exists_or_fetch(&base_branch)? {
89            return Err(CascadeError::branch(format!(
90                "Base branch '{base_branch}' does not exist locally or remotely"
91            )));
92        }
93
94        // Create the stack
95        let stack = Stack::new(name.clone(), base_branch.clone(), description.clone());
96        let stack_id = stack.id;
97
98        // Create metadata
99        let stack_metadata = StackMetadata::new(stack_id, name, base_branch, description);
100
101        // Store in memory
102        self.stacks.insert(stack_id, stack);
103        self.metadata.add_stack(stack_metadata);
104
105        // Set as active if it's the first stack
106        if self.metadata.stacks.len() == 1 {
107            self.set_active_stack(Some(stack_id))?;
108        } else {
109            // Just save to disk if not setting as active
110            self.save_to_disk()?;
111        }
112
113        Ok(stack_id)
114    }
115
116    /// Get a stack by ID
117    pub fn get_stack(&self, stack_id: &Uuid) -> Option<&Stack> {
118        self.stacks.get(stack_id)
119    }
120
121    /// Get a mutable stack by ID
122    pub fn get_stack_mut(&mut self, stack_id: &Uuid) -> Option<&mut Stack> {
123        self.stacks.get_mut(stack_id)
124    }
125
126    /// Get stack by name
127    pub fn get_stack_by_name(&self, name: &str) -> Option<&Stack> {
128        if let Some(metadata) = self.metadata.find_stack_by_name(name) {
129            self.stacks.get(&metadata.stack_id)
130        } else {
131            None
132        }
133    }
134
135    /// Get the currently active stack
136    pub fn get_active_stack(&self) -> Option<&Stack> {
137        self.metadata
138            .active_stack_id
139            .and_then(|id| self.stacks.get(&id))
140    }
141
142    /// Get the currently active stack mutably
143    pub fn get_active_stack_mut(&mut self) -> Option<&mut Stack> {
144        if let Some(id) = self.metadata.active_stack_id {
145            self.stacks.get_mut(&id)
146        } else {
147            None
148        }
149    }
150
151    /// Set the active stack
152    pub fn set_active_stack(&mut self, stack_id: Option<Uuid>) -> Result<()> {
153        // Verify stack exists if provided
154        if let Some(id) = stack_id {
155            if !self.stacks.contains_key(&id) {
156                return Err(CascadeError::config(format!(
157                    "Stack with ID {id} not found"
158                )));
159            }
160        }
161
162        // Update active flag on stacks
163        for stack in self.stacks.values_mut() {
164            stack.set_active(Some(stack.id) == stack_id);
165        }
166
167        // Track the current branch when activating a stack
168        if let Some(id) = stack_id {
169            let current_branch = self.repo.get_current_branch().ok();
170            if let Some(stack_meta) = self.metadata.get_stack_mut(&id) {
171                stack_meta.set_current_branch(current_branch);
172            }
173        }
174
175        self.metadata.set_active_stack(stack_id);
176        self.save_to_disk()?;
177
178        Ok(())
179    }
180
181    /// Set active stack by name
182    pub fn set_active_stack_by_name(&mut self, name: &str) -> Result<()> {
183        if let Some(metadata) = self.metadata.find_stack_by_name(name) {
184            self.set_active_stack(Some(metadata.stack_id))
185        } else {
186            Err(CascadeError::config(format!("Stack '{name}' not found")))
187        }
188    }
189
190    /// Delete a stack
191    pub fn delete_stack(&mut self, stack_id: &Uuid) -> Result<Stack> {
192        let stack = self
193            .stacks
194            .remove(stack_id)
195            .ok_or_else(|| CascadeError::config(format!("Stack with ID {stack_id} not found")))?;
196
197        // Remove metadata
198        self.metadata.remove_stack(stack_id);
199
200        // Remove all associated commit metadata
201        let stack_commits: Vec<String> = self
202            .metadata
203            .commits
204            .values()
205            .filter(|commit| &commit.stack_id == stack_id)
206            .map(|commit| commit.hash.clone())
207            .collect();
208
209        for commit_hash in stack_commits {
210            self.metadata.remove_commit(&commit_hash);
211        }
212
213        // If this was the active stack, find a new one
214        if self.metadata.active_stack_id == Some(*stack_id) {
215            let new_active = self.metadata.stacks.keys().next().copied();
216            self.set_active_stack(new_active)?;
217        }
218
219        self.save_to_disk()?;
220
221        Ok(stack)
222    }
223
224    /// Push a commit to a stack
225    pub fn push_to_stack(
226        &mut self,
227        branch: String,
228        commit_hash: String,
229        message: String,
230        source_branch: String,
231    ) -> Result<Uuid> {
232        let stack_id = self
233            .metadata
234            .active_stack_id
235            .ok_or_else(|| CascadeError::config("No active stack"))?;
236
237        let stack = self
238            .stacks
239            .get_mut(&stack_id)
240            .ok_or_else(|| CascadeError::config("Active stack not found"))?;
241
242        // Verify the commit exists
243        if !self.repo.commit_exists(&commit_hash)? {
244            return Err(CascadeError::branch(format!(
245                "Commit {commit_hash} does not exist"
246            )));
247        }
248
249        // Check for duplicate commit messages within the same stack
250        if let Some(duplicate_entry) = stack.entries.iter().find(|entry| entry.message == message) {
251            return Err(CascadeError::validation(format!(
252                "Duplicate commit message in stack: \"{message}\"\n\n\
253                 This message already exists in entry {} (commit: {})\n\n\
254                 💡 Consider using a more specific message:\n\
255                    • Add context: \"{message} - add validation\"\n\
256                    • Be more specific: \"Fix user authentication timeout bug\"\n\
257                    • Or amend the previous commit: git commit --amend",
258                duplicate_entry.id,
259                &duplicate_entry.commit_hash[..8]
260            )));
261        }
262
263        // Add to stack
264        let entry_id = stack.push_entry(branch.clone(), commit_hash.clone(), message.clone());
265
266        // Create commit metadata
267        let commit_metadata = CommitMetadata::new(
268            commit_hash.clone(),
269            message,
270            entry_id,
271            stack_id,
272            branch.clone(),
273            source_branch,
274        );
275
276        // Update repository metadata
277        self.metadata.add_commit(commit_metadata);
278        if let Some(stack_meta) = self.metadata.get_stack_mut(&stack_id) {
279            stack_meta.add_branch(branch);
280            stack_meta.add_commit(commit_hash);
281        }
282
283        self.save_to_disk()?;
284
285        Ok(entry_id)
286    }
287
288    /// Pop the top commit from the active stack
289    pub fn pop_from_stack(&mut self) -> Result<StackEntry> {
290        let stack_id = self
291            .metadata
292            .active_stack_id
293            .ok_or_else(|| CascadeError::config("No active stack"))?;
294
295        let stack = self
296            .stacks
297            .get_mut(&stack_id)
298            .ok_or_else(|| CascadeError::config("Active stack not found"))?;
299
300        let entry = stack
301            .pop_entry()
302            .ok_or_else(|| CascadeError::config("Stack is empty"))?;
303
304        // Remove commit metadata
305        self.metadata.remove_commit(&entry.commit_hash);
306
307        // Update stack metadata
308        if let Some(stack_meta) = self.metadata.get_stack_mut(&stack_id) {
309            stack_meta.remove_commit(&entry.commit_hash);
310            // Note: We don't remove the branch as there might be other commits on it
311        }
312
313        self.save_to_disk()?;
314
315        Ok(entry)
316    }
317
318    /// Submit a stack entry for review (mark as submitted)
319    pub fn submit_entry(
320        &mut self,
321        stack_id: &Uuid,
322        entry_id: &Uuid,
323        pull_request_id: String,
324    ) -> Result<()> {
325        let stack = self
326            .stacks
327            .get_mut(stack_id)
328            .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
329
330        let entry_commit_hash = {
331            let entry = stack
332                .get_entry(entry_id)
333                .ok_or_else(|| CascadeError::config(format!("Entry {entry_id} not found")))?;
334            entry.commit_hash.clone()
335        };
336
337        // Update stack entry
338        if !stack.mark_entry_submitted(entry_id, pull_request_id.clone()) {
339            return Err(CascadeError::config(format!(
340                "Failed to mark entry {entry_id} as submitted"
341            )));
342        }
343
344        // Update commit metadata
345        if let Some(commit_meta) = self.metadata.commits.get_mut(&entry_commit_hash) {
346            commit_meta.mark_submitted(pull_request_id);
347        }
348
349        // Update stack metadata statistics
350        if let Some(stack_meta) = self.metadata.get_stack_mut(stack_id) {
351            let submitted_count = stack.entries.iter().filter(|e| e.is_submitted).count();
352            stack_meta.update_stats(
353                stack.entries.len(),
354                submitted_count,
355                stack_meta.merged_commits,
356            );
357        }
358
359        self.save_to_disk()?;
360
361        Ok(())
362    }
363
364    /// Get all stacks
365    pub fn get_all_stacks(&self) -> Vec<&Stack> {
366        self.stacks.values().collect()
367    }
368
369    /// Get stack metadata
370    pub fn get_stack_metadata(&self, stack_id: &Uuid) -> Option<&StackMetadata> {
371        self.metadata.get_stack(stack_id)
372    }
373
374    /// Get repository metadata
375    pub fn get_repository_metadata(&self) -> &RepositoryMetadata {
376        &self.metadata
377    }
378
379    /// Get the Git repository
380    pub fn git_repo(&self) -> &GitRepository {
381        &self.repo
382    }
383
384    /// Get the repository path
385    pub fn repo_path(&self) -> &Path {
386        &self.repo_path
387    }
388
389    // Edit mode management methods
390
391    /// Check if currently in edit mode
392    pub fn is_in_edit_mode(&self) -> bool {
393        self.metadata
394            .edit_mode
395            .as_ref()
396            .map(|edit_state| edit_state.is_active)
397            .unwrap_or(false)
398    }
399
400    /// Get current edit mode information
401    pub fn get_edit_mode_info(&self) -> Option<&super::metadata::EditModeState> {
402        self.metadata.edit_mode.as_ref()
403    }
404
405    /// Enter edit mode for a specific stack entry
406    pub fn enter_edit_mode(&mut self, stack_id: Uuid, entry_id: Uuid) -> Result<()> {
407        // Get the commit hash first to avoid borrow checker issues
408        let commit_hash = {
409            let stack = self
410                .get_stack(&stack_id)
411                .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
412
413            let entry = stack.get_entry(&entry_id).ok_or_else(|| {
414                CascadeError::config(format!("Entry {entry_id} not found in stack"))
415            })?;
416
417            entry.commit_hash.clone()
418        };
419
420        // If already in edit mode, exit the current one first
421        if self.is_in_edit_mode() {
422            self.exit_edit_mode()?;
423        }
424
425        // Create new edit mode state
426        let edit_state = super::metadata::EditModeState::new(stack_id, entry_id, commit_hash);
427
428        self.metadata.edit_mode = Some(edit_state);
429        self.save_to_disk()?;
430
431        info!(
432            "Entered edit mode for entry {} in stack {}",
433            entry_id, stack_id
434        );
435        Ok(())
436    }
437
438    /// Exit edit mode
439    pub fn exit_edit_mode(&mut self) -> Result<()> {
440        if !self.is_in_edit_mode() {
441            return Err(CascadeError::config("Not currently in edit mode"));
442        }
443
444        // Clear edit mode state
445        self.metadata.edit_mode = None;
446        self.save_to_disk()?;
447
448        info!("Exited edit mode");
449        Ok(())
450    }
451
452    /// Sync stack with Git repository state
453    pub fn sync_stack(&mut self, stack_id: &Uuid) -> Result<()> {
454        let stack = self
455            .stacks
456            .get_mut(stack_id)
457            .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
458
459        // Check if all commits still exist
460        let mut missing_commits = Vec::new();
461        for entry in &stack.entries {
462            if !self.repo.commit_exists(&entry.commit_hash)? {
463                missing_commits.push(entry.commit_hash.clone());
464            }
465        }
466
467        if !missing_commits.is_empty() {
468            stack.update_status(StackStatus::OutOfSync);
469            return Err(CascadeError::branch(format!(
470                "Stack {} has missing commits: {}",
471                stack.name,
472                missing_commits.join(", ")
473            )));
474        }
475
476        // Check if base branch exists and has new commits (try to fetch from remote if not local)
477        if !self.repo.branch_exists_or_fetch(&stack.base_branch)? {
478            return Err(CascadeError::branch(format!(
479                "Base branch '{}' does not exist locally or remotely. Check the branch name or switch to a different base.",
480                stack.base_branch
481            )));
482        }
483
484        let _base_hash = self.repo.get_branch_head(&stack.base_branch)?;
485
486        // Check if any stack entries are missing their commits
487        let mut corrupted_entry = None;
488        for entry in &stack.entries {
489            if !self.repo.commit_exists(&entry.commit_hash)? {
490                corrupted_entry = Some((entry.commit_hash.clone(), entry.branch.clone()));
491                break;
492            }
493        }
494
495        if let Some((commit_hash, branch)) = corrupted_entry {
496            stack.update_status(StackStatus::Corrupted);
497            return Err(CascadeError::branch(format!(
498                "Commit {commit_hash} from stack entry '{branch}' no longer exists"
499            )));
500        }
501
502        // Compare base branch with the earliest commit in the stack
503        let needs_sync = if let Some(first_entry) = stack.entries.first() {
504            // Get commits between base and first entry
505            match self
506                .repo
507                .get_commits_between(&stack.base_branch, &first_entry.commit_hash)
508            {
509                Ok(commits) => !commits.is_empty(), // If there are commits, we need to sync
510                Err(_) => true,                     // If we can't compare, assume we need to sync
511            }
512        } else {
513            false // Empty stack is always clean
514        };
515
516        // Update stack status based on sync needs
517        if needs_sync {
518            stack.update_status(StackStatus::NeedsSync);
519            info!(
520                "Stack '{}' needs sync - new commits on base branch",
521                stack.name
522            );
523        } else {
524            stack.update_status(StackStatus::Clean);
525            info!("Stack '{}' is clean", stack.name);
526        }
527
528        // Update metadata
529        if let Some(stack_meta) = self.metadata.get_stack_mut(stack_id) {
530            stack_meta.set_up_to_date(true);
531        }
532
533        self.save_to_disk()?;
534
535        Ok(())
536    }
537
538    /// List all stacks with their status
539    pub fn list_stacks(&self) -> Vec<(Uuid, &str, &StackStatus, usize, Option<&str>)> {
540        self.stacks
541            .values()
542            .map(|stack| {
543                (
544                    stack.id,
545                    stack.name.as_str(),
546                    &stack.status,
547                    stack.entries.len(),
548                    if stack.is_active {
549                        Some("active")
550                    } else {
551                        None
552                    },
553                )
554            })
555            .collect()
556    }
557
558    /// Get all stacks as Stack objects for TUI
559    pub fn get_all_stacks_objects(&self) -> Result<Vec<Stack>> {
560        let mut stacks: Vec<Stack> = self.stacks.values().cloned().collect();
561        stacks.sort_by(|a, b| a.name.cmp(&b.name));
562        Ok(stacks)
563    }
564
565    /// Validate all stacks
566    pub fn validate_all(&self) -> Result<()> {
567        for stack in self.stacks.values() {
568            stack.validate().map_err(|e| {
569                CascadeError::config(format!("Stack '{}' validation failed: {}", stack.name, e))
570            })?;
571        }
572        Ok(())
573    }
574
575    /// Save all data to disk
576    fn save_to_disk(&self) -> Result<()> {
577        // Ensure config directory exists
578        if !self.config_dir.exists() {
579            fs::create_dir_all(&self.config_dir).map_err(|e| {
580                CascadeError::config(format!("Failed to create config directory: {e}"))
581            })?;
582        }
583
584        // Save stacks atomically
585        crate::utils::atomic_file::write_json(&self.stacks_file, &self.stacks)?;
586
587        // Save metadata atomically
588        crate::utils::atomic_file::write_json(&self.metadata_file, &self.metadata)?;
589
590        Ok(())
591    }
592
593    /// Load data from disk
594    fn load_from_disk(&mut self) -> Result<()> {
595        // Load stacks if file exists
596        if self.stacks_file.exists() {
597            let stacks_content = fs::read_to_string(&self.stacks_file)
598                .map_err(|e| CascadeError::config(format!("Failed to read stacks file: {e}")))?;
599
600            self.stacks = serde_json::from_str(&stacks_content)
601                .map_err(|e| CascadeError::config(format!("Failed to parse stacks file: {e}")))?;
602        }
603
604        // Load metadata if file exists
605        if self.metadata_file.exists() {
606            let metadata_content = fs::read_to_string(&self.metadata_file)
607                .map_err(|e| CascadeError::config(format!("Failed to read metadata file: {e}")))?;
608
609            self.metadata = serde_json::from_str(&metadata_content)
610                .map_err(|e| CascadeError::config(format!("Failed to parse metadata file: {e}")))?;
611        }
612
613        Ok(())
614    }
615
616    /// Check if the user has changed branches since the stack was activated
617    /// Returns true if branch change detected and user wants to proceed
618    pub fn check_for_branch_change(&mut self) -> Result<bool> {
619        // Extract stack information first to avoid borrow conflicts
620        let (stack_id, stack_name, stored_branch) = {
621            if let Some(active_stack) = self.get_active_stack() {
622                let stack_id = active_stack.id;
623                let stack_name = active_stack.name.clone();
624                let stored_branch = if let Some(stack_meta) = self.metadata.get_stack(&stack_id) {
625                    stack_meta.current_branch.clone()
626                } else {
627                    None
628                };
629                (Some(stack_id), stack_name, stored_branch)
630            } else {
631                (None, String::new(), None)
632            }
633        };
634
635        // If no active stack, nothing to check
636        let Some(stack_id) = stack_id else {
637            return Ok(true);
638        };
639
640        let current_branch = self.repo.get_current_branch().ok();
641
642        // Check if branch has changed
643        if stored_branch.as_ref() != current_branch.as_ref() {
644            println!("⚠️  Branch change detected!");
645            println!(
646                "   Stack '{}' was active on: {}",
647                stack_name,
648                stored_branch.as_deref().unwrap_or("unknown")
649            );
650            println!(
651                "   Current branch: {}",
652                current_branch.as_deref().unwrap_or("unknown")
653            );
654            println!();
655            println!("What would you like to do?");
656            println!("   1. Keep stack '{stack_name}' active (continue with stack workflow)");
657            println!("   2. Deactivate stack (use normal Git workflow)");
658            println!("   3. Switch to a different stack");
659            println!("   4. Cancel and stay on current workflow");
660            print!("   Choice (1-4): ");
661
662            use std::io::{self, Write};
663            io::stdout()
664                .flush()
665                .map_err(|e| CascadeError::config(format!("Failed to write to stdout: {e}")))?;
666
667            let mut input = String::new();
668            io::stdin()
669                .read_line(&mut input)
670                .map_err(|e| CascadeError::config(format!("Failed to read user input: {e}")))?;
671
672            match input.trim() {
673                "1" => {
674                    // Update the tracked branch and continue
675                    if let Some(stack_meta) = self.metadata.get_stack_mut(&stack_id) {
676                        stack_meta.set_current_branch(current_branch);
677                    }
678                    self.save_to_disk()?;
679                    println!("✅ Continuing with stack '{stack_name}' on current branch");
680                    return Ok(true);
681                }
682                "2" => {
683                    // Deactivate the stack
684                    self.set_active_stack(None)?;
685                    println!("✅ Deactivated stack '{stack_name}' - using normal Git workflow");
686                    return Ok(false);
687                }
688                "3" => {
689                    // Show available stacks
690                    let stacks = self.get_all_stacks();
691                    if stacks.len() <= 1 {
692                        println!("⚠️  No other stacks available. Deactivating current stack.");
693                        self.set_active_stack(None)?;
694                        return Ok(false);
695                    }
696
697                    println!("\nAvailable stacks:");
698                    for (i, stack) in stacks.iter().enumerate() {
699                        if stack.id != stack_id {
700                            println!("   {}. {}", i + 1, stack.name);
701                        }
702                    }
703                    print!("   Enter stack name: ");
704                    io::stdout().flush().map_err(|e| {
705                        CascadeError::config(format!("Failed to write to stdout: {e}"))
706                    })?;
707
708                    let mut stack_name_input = String::new();
709                    io::stdin().read_line(&mut stack_name_input).map_err(|e| {
710                        CascadeError::config(format!("Failed to read user input: {e}"))
711                    })?;
712                    let stack_name_input = stack_name_input.trim();
713
714                    if let Err(e) = self.set_active_stack_by_name(stack_name_input) {
715                        println!("⚠️  {e}");
716                        println!("   Deactivating stack instead.");
717                        self.set_active_stack(None)?;
718                        return Ok(false);
719                    } else {
720                        println!("✅ Switched to stack '{stack_name_input}'");
721                        return Ok(true);
722                    }
723                }
724                _ => {
725                    println!("Cancelled - no changes made");
726                    return Ok(false);
727                }
728            }
729        }
730
731        // No branch change detected
732        Ok(true)
733    }
734}
735
736#[cfg(test)]
737mod tests {
738    use super::*;
739    use std::process::Command;
740    use tempfile::TempDir;
741
742    fn create_test_repo() -> (TempDir, PathBuf) {
743        let temp_dir = TempDir::new().unwrap();
744        let repo_path = temp_dir.path().to_path_buf();
745
746        // Initialize git repository
747        Command::new("git")
748            .args(["init"])
749            .current_dir(&repo_path)
750            .output()
751            .unwrap();
752
753        // Configure git
754        Command::new("git")
755            .args(["config", "user.name", "Test User"])
756            .current_dir(&repo_path)
757            .output()
758            .unwrap();
759
760        Command::new("git")
761            .args(["config", "user.email", "test@example.com"])
762            .current_dir(&repo_path)
763            .output()
764            .unwrap();
765
766        // Create an initial commit
767        std::fs::write(repo_path.join("README.md"), "# Test Repo").unwrap();
768        Command::new("git")
769            .args(["add", "."])
770            .current_dir(&repo_path)
771            .output()
772            .unwrap();
773
774        Command::new("git")
775            .args(["commit", "-m", "Initial commit"])
776            .current_dir(&repo_path)
777            .output()
778            .unwrap();
779
780        // Initialize cascade
781        crate::config::initialize_repo(&repo_path, Some("https://test.bitbucket.com".to_string()))
782            .unwrap();
783
784        (temp_dir, repo_path)
785    }
786
787    #[test]
788    fn test_create_stack_manager() {
789        let (_temp_dir, repo_path) = create_test_repo();
790        let manager = StackManager::new(&repo_path).unwrap();
791
792        assert_eq!(manager.stacks.len(), 0);
793        assert!(manager.get_active_stack().is_none());
794    }
795
796    #[test]
797    fn test_create_and_manage_stack() {
798        let (_temp_dir, repo_path) = create_test_repo();
799        let mut manager = StackManager::new(&repo_path).unwrap();
800
801        // Create a stack using the default branch
802        let stack_id = manager
803            .create_stack(
804                "test-stack".to_string(),
805                None, // Use default branch
806                Some("Test stack description".to_string()),
807            )
808            .unwrap();
809
810        // Verify stack was created
811        assert_eq!(manager.stacks.len(), 1);
812        let stack = manager.get_stack(&stack_id).unwrap();
813        assert_eq!(stack.name, "test-stack");
814        // Should use the default branch (which gets set from the Git repo)
815        assert!(!stack.base_branch.is_empty());
816        assert!(stack.is_active);
817
818        // Verify it's the active stack
819        let active = manager.get_active_stack().unwrap();
820        assert_eq!(active.id, stack_id);
821
822        // Test get by name
823        let found = manager.get_stack_by_name("test-stack").unwrap();
824        assert_eq!(found.id, stack_id);
825    }
826
827    #[test]
828    fn test_stack_persistence() {
829        let (_temp_dir, repo_path) = create_test_repo();
830
831        let stack_id = {
832            let mut manager = StackManager::new(&repo_path).unwrap();
833            manager
834                .create_stack("persistent-stack".to_string(), None, None)
835                .unwrap()
836        };
837
838        // Create new manager and verify data was loaded
839        let manager = StackManager::new(&repo_path).unwrap();
840        assert_eq!(manager.stacks.len(), 1);
841        let stack = manager.get_stack(&stack_id).unwrap();
842        assert_eq!(stack.name, "persistent-stack");
843    }
844
845    #[test]
846    fn test_multiple_stacks() {
847        let (_temp_dir, repo_path) = create_test_repo();
848        let mut manager = StackManager::new(&repo_path).unwrap();
849
850        let stack1_id = manager
851            .create_stack("stack-1".to_string(), None, None)
852            .unwrap();
853        let stack2_id = manager
854            .create_stack("stack-2".to_string(), None, None)
855            .unwrap();
856
857        assert_eq!(manager.stacks.len(), 2);
858
859        // First stack should still be active
860        assert!(manager.get_stack(&stack1_id).unwrap().is_active);
861        assert!(!manager.get_stack(&stack2_id).unwrap().is_active);
862
863        // Change active stack
864        manager.set_active_stack(Some(stack2_id)).unwrap();
865        assert!(!manager.get_stack(&stack1_id).unwrap().is_active);
866        assert!(manager.get_stack(&stack2_id).unwrap().is_active);
867    }
868
869    #[test]
870    fn test_delete_stack() {
871        let (_temp_dir, repo_path) = create_test_repo();
872        let mut manager = StackManager::new(&repo_path).unwrap();
873
874        let stack_id = manager
875            .create_stack("to-delete".to_string(), None, None)
876            .unwrap();
877        assert_eq!(manager.stacks.len(), 1);
878
879        let deleted = manager.delete_stack(&stack_id).unwrap();
880        assert_eq!(deleted.name, "to-delete");
881        assert_eq!(manager.stacks.len(), 0);
882        assert!(manager.get_active_stack().is_none());
883    }
884
885    #[test]
886    fn test_validation() {
887        let (_temp_dir, repo_path) = create_test_repo();
888        let mut manager = StackManager::new(&repo_path).unwrap();
889
890        manager
891            .create_stack("valid-stack".to_string(), None, None)
892            .unwrap();
893
894        // Should pass validation
895        assert!(manager.validate_all().is_ok());
896    }
897
898    #[test]
899    fn test_duplicate_commit_message_detection() {
900        let (_temp_dir, repo_path) = create_test_repo();
901        let mut manager = StackManager::new(&repo_path).unwrap();
902
903        // Create a stack
904        manager
905            .create_stack("test-stack".to_string(), None, None)
906            .unwrap();
907
908        // Create first commit
909        std::fs::write(repo_path.join("file1.txt"), "content1").unwrap();
910        Command::new("git")
911            .args(["add", "file1.txt"])
912            .current_dir(&repo_path)
913            .output()
914            .unwrap();
915
916        Command::new("git")
917            .args(["commit", "-m", "Add authentication feature"])
918            .current_dir(&repo_path)
919            .output()
920            .unwrap();
921
922        let commit1_hash = Command::new("git")
923            .args(["rev-parse", "HEAD"])
924            .current_dir(&repo_path)
925            .output()
926            .unwrap();
927        let commit1_hash = String::from_utf8_lossy(&commit1_hash.stdout)
928            .trim()
929            .to_string();
930
931        // Push first commit to stack - should succeed
932        let entry1_id = manager
933            .push_to_stack(
934                "feature/auth".to_string(),
935                commit1_hash,
936                "Add authentication feature".to_string(),
937                "main".to_string(),
938            )
939            .unwrap();
940
941        // Verify first entry was added
942        assert!(manager
943            .get_active_stack()
944            .unwrap()
945            .get_entry(&entry1_id)
946            .is_some());
947
948        // Create second commit
949        std::fs::write(repo_path.join("file2.txt"), "content2").unwrap();
950        Command::new("git")
951            .args(["add", "file2.txt"])
952            .current_dir(&repo_path)
953            .output()
954            .unwrap();
955
956        Command::new("git")
957            .args(["commit", "-m", "Different commit message"])
958            .current_dir(&repo_path)
959            .output()
960            .unwrap();
961
962        let commit2_hash = Command::new("git")
963            .args(["rev-parse", "HEAD"])
964            .current_dir(&repo_path)
965            .output()
966            .unwrap();
967        let commit2_hash = String::from_utf8_lossy(&commit2_hash.stdout)
968            .trim()
969            .to_string();
970
971        // Try to push second commit with the SAME message - should fail
972        let result = manager.push_to_stack(
973            "feature/auth2".to_string(),
974            commit2_hash.clone(),
975            "Add authentication feature".to_string(), // Same message as first commit
976            "main".to_string(),
977        );
978
979        // Should fail with validation error
980        assert!(result.is_err());
981        let error = result.unwrap_err();
982        assert!(matches!(error, CascadeError::Validation(_)));
983
984        // Error message should contain helpful information
985        let error_msg = error.to_string();
986        assert!(error_msg.contains("Duplicate commit message"));
987        assert!(error_msg.contains("Add authentication feature"));
988        assert!(error_msg.contains("💡 Consider using a more specific message"));
989
990        // Push with different message - should succeed
991        let entry2_id = manager
992            .push_to_stack(
993                "feature/auth2".to_string(),
994                commit2_hash,
995                "Add authentication validation".to_string(), // Different message
996                "main".to_string(),
997            )
998            .unwrap();
999
1000        // Verify both entries exist
1001        let stack = manager.get_active_stack().unwrap();
1002        assert_eq!(stack.entries.len(), 2);
1003        assert!(stack.get_entry(&entry1_id).is_some());
1004        assert!(stack.get_entry(&entry2_id).is_some());
1005    }
1006
1007    #[test]
1008    fn test_duplicate_message_with_different_case() {
1009        let (_temp_dir, repo_path) = create_test_repo();
1010        let mut manager = StackManager::new(&repo_path).unwrap();
1011
1012        manager
1013            .create_stack("test-stack".to_string(), None, None)
1014            .unwrap();
1015
1016        // Create and push first commit
1017        std::fs::write(repo_path.join("file1.txt"), "content1").unwrap();
1018        Command::new("git")
1019            .args(["add", "file1.txt"])
1020            .current_dir(&repo_path)
1021            .output()
1022            .unwrap();
1023
1024        Command::new("git")
1025            .args(["commit", "-m", "fix bug"])
1026            .current_dir(&repo_path)
1027            .output()
1028            .unwrap();
1029
1030        let commit1_hash = Command::new("git")
1031            .args(["rev-parse", "HEAD"])
1032            .current_dir(&repo_path)
1033            .output()
1034            .unwrap();
1035        let commit1_hash = String::from_utf8_lossy(&commit1_hash.stdout)
1036            .trim()
1037            .to_string();
1038
1039        manager
1040            .push_to_stack(
1041                "feature/fix1".to_string(),
1042                commit1_hash,
1043                "fix bug".to_string(),
1044                "main".to_string(),
1045            )
1046            .unwrap();
1047
1048        // Create second commit
1049        std::fs::write(repo_path.join("file2.txt"), "content2").unwrap();
1050        Command::new("git")
1051            .args(["add", "file2.txt"])
1052            .current_dir(&repo_path)
1053            .output()
1054            .unwrap();
1055
1056        Command::new("git")
1057            .args(["commit", "-m", "Fix Bug"])
1058            .current_dir(&repo_path)
1059            .output()
1060            .unwrap();
1061
1062        let commit2_hash = Command::new("git")
1063            .args(["rev-parse", "HEAD"])
1064            .current_dir(&repo_path)
1065            .output()
1066            .unwrap();
1067        let commit2_hash = String::from_utf8_lossy(&commit2_hash.stdout)
1068            .trim()
1069            .to_string();
1070
1071        // Different case should be allowed (case-sensitive comparison)
1072        let result = manager.push_to_stack(
1073            "feature/fix2".to_string(),
1074            commit2_hash,
1075            "Fix Bug".to_string(), // Different case
1076            "main".to_string(),
1077        );
1078
1079        // Should succeed because it's case-sensitive
1080        assert!(result.is_ok());
1081    }
1082
1083    #[test]
1084    fn test_duplicate_message_across_different_stacks() {
1085        let (_temp_dir, repo_path) = create_test_repo();
1086        let mut manager = StackManager::new(&repo_path).unwrap();
1087
1088        // Create first stack and push commit
1089        let stack1_id = manager
1090            .create_stack("stack1".to_string(), None, None)
1091            .unwrap();
1092
1093        std::fs::write(repo_path.join("file1.txt"), "content1").unwrap();
1094        Command::new("git")
1095            .args(["add", "file1.txt"])
1096            .current_dir(&repo_path)
1097            .output()
1098            .unwrap();
1099
1100        Command::new("git")
1101            .args(["commit", "-m", "shared message"])
1102            .current_dir(&repo_path)
1103            .output()
1104            .unwrap();
1105
1106        let commit1_hash = Command::new("git")
1107            .args(["rev-parse", "HEAD"])
1108            .current_dir(&repo_path)
1109            .output()
1110            .unwrap();
1111        let commit1_hash = String::from_utf8_lossy(&commit1_hash.stdout)
1112            .trim()
1113            .to_string();
1114
1115        manager
1116            .push_to_stack(
1117                "feature/shared1".to_string(),
1118                commit1_hash,
1119                "shared message".to_string(),
1120                "main".to_string(),
1121            )
1122            .unwrap();
1123
1124        // Create second stack
1125        let stack2_id = manager
1126            .create_stack("stack2".to_string(), None, None)
1127            .unwrap();
1128
1129        // Set second stack as active
1130        manager.set_active_stack(Some(stack2_id)).unwrap();
1131
1132        // Create commit for second stack
1133        std::fs::write(repo_path.join("file2.txt"), "content2").unwrap();
1134        Command::new("git")
1135            .args(["add", "file2.txt"])
1136            .current_dir(&repo_path)
1137            .output()
1138            .unwrap();
1139
1140        Command::new("git")
1141            .args(["commit", "-m", "shared message"])
1142            .current_dir(&repo_path)
1143            .output()
1144            .unwrap();
1145
1146        let commit2_hash = Command::new("git")
1147            .args(["rev-parse", "HEAD"])
1148            .current_dir(&repo_path)
1149            .output()
1150            .unwrap();
1151        let commit2_hash = String::from_utf8_lossy(&commit2_hash.stdout)
1152            .trim()
1153            .to_string();
1154
1155        // Same message in different stack should be allowed
1156        let result = manager.push_to_stack(
1157            "feature/shared2".to_string(),
1158            commit2_hash,
1159            "shared message".to_string(), // Same message but different stack
1160            "main".to_string(),
1161        );
1162
1163        // Should succeed because it's a different stack
1164        assert!(result.is_ok());
1165
1166        // Verify both stacks have entries with the same message
1167        let stack1 = manager.get_stack(&stack1_id).unwrap();
1168        let stack2 = manager.get_stack(&stack2_id).unwrap();
1169
1170        assert_eq!(stack1.entries.len(), 1);
1171        assert_eq!(stack2.entries.len(), 1);
1172        assert_eq!(stack1.entries[0].message, "shared message");
1173        assert_eq!(stack2.entries[0].message, "shared message");
1174    }
1175
1176    #[test]
1177    fn test_duplicate_after_pop() {
1178        let (_temp_dir, repo_path) = create_test_repo();
1179        let mut manager = StackManager::new(&repo_path).unwrap();
1180
1181        manager
1182            .create_stack("test-stack".to_string(), None, None)
1183            .unwrap();
1184
1185        // Create and push first commit
1186        std::fs::write(repo_path.join("file1.txt"), "content1").unwrap();
1187        Command::new("git")
1188            .args(["add", "file1.txt"])
1189            .current_dir(&repo_path)
1190            .output()
1191            .unwrap();
1192
1193        Command::new("git")
1194            .args(["commit", "-m", "temporary message"])
1195            .current_dir(&repo_path)
1196            .output()
1197            .unwrap();
1198
1199        let commit1_hash = Command::new("git")
1200            .args(["rev-parse", "HEAD"])
1201            .current_dir(&repo_path)
1202            .output()
1203            .unwrap();
1204        let commit1_hash = String::from_utf8_lossy(&commit1_hash.stdout)
1205            .trim()
1206            .to_string();
1207
1208        manager
1209            .push_to_stack(
1210                "feature/temp".to_string(),
1211                commit1_hash,
1212                "temporary message".to_string(),
1213                "main".to_string(),
1214            )
1215            .unwrap();
1216
1217        // Pop the entry
1218        let popped = manager.pop_from_stack().unwrap();
1219        assert_eq!(popped.message, "temporary message");
1220
1221        // Create new commit
1222        std::fs::write(repo_path.join("file2.txt"), "content2").unwrap();
1223        Command::new("git")
1224            .args(["add", "file2.txt"])
1225            .current_dir(&repo_path)
1226            .output()
1227            .unwrap();
1228
1229        Command::new("git")
1230            .args(["commit", "-m", "temporary message"])
1231            .current_dir(&repo_path)
1232            .output()
1233            .unwrap();
1234
1235        let commit2_hash = Command::new("git")
1236            .args(["rev-parse", "HEAD"])
1237            .current_dir(&repo_path)
1238            .output()
1239            .unwrap();
1240        let commit2_hash = String::from_utf8_lossy(&commit2_hash.stdout)
1241            .trim()
1242            .to_string();
1243
1244        // Should be able to push same message again after popping
1245        let result = manager.push_to_stack(
1246            "feature/temp2".to_string(),
1247            commit2_hash,
1248            "temporary message".to_string(),
1249            "main".to_string(),
1250        );
1251
1252        assert!(result.is_ok());
1253    }
1254}