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 chrono::Utc;
8use dialoguer::{theme::ColorfulTheme, Input, Select};
9use std::collections::HashMap;
10use std::fs;
11use std::path::{Path, PathBuf};
12use tracing::{debug, warn};
13use uuid::Uuid;
14
15#[derive(Debug)]
17pub enum BranchModification {
18 Missing {
20 branch: String,
21 entry_id: Uuid,
22 expected_commit: String,
23 },
24 ExtraCommits {
26 branch: String,
27 entry_id: Uuid,
28 expected_commit: String,
29 actual_commit: String,
30 extra_commit_count: usize,
31 extra_commit_messages: Vec<String>,
32 },
33}
34
35pub struct StackManager {
37 repo: GitRepository,
39 repo_path: PathBuf,
41 config_dir: PathBuf,
43 stacks_file: PathBuf,
45 metadata_file: PathBuf,
47 stacks: HashMap<Uuid, Stack>,
49 metadata: RepositoryMetadata,
51}
52
53impl StackManager {
54 pub fn new(repo_path: &Path) -> Result<Self> {
56 let repo = GitRepository::open(repo_path)?;
57 let config_dir = get_repo_config_dir(repo_path)?;
58 let stacks_file = config_dir.join("stacks.json");
59 let metadata_file = config_dir.join("metadata.json");
60
61 let config_file = config_dir.join("config.json");
63 let settings = Settings::load_from_file(&config_file).unwrap_or_default();
64 let configured_default = &settings.git.default_branch;
65
66 let default_base = if repo.branch_exists(configured_default) {
70 configured_default.clone()
72 } else {
73 match repo.detect_main_branch() {
75 Ok(detected) => {
76 detected
78 }
79 Err(_) => {
80 configured_default.clone()
83 }
84 }
85 };
86
87 let mut manager = Self {
88 repo,
89 repo_path: repo_path.to_path_buf(),
90 config_dir,
91 stacks_file,
92 metadata_file,
93 stacks: HashMap::new(),
94 metadata: RepositoryMetadata::new(default_base),
95 };
96
97 manager.load_from_disk()?;
99
100 Ok(manager)
101 }
102
103 pub fn create_stack(
105 &mut self,
106 name: String,
107 base_branch: Option<String>,
108 description: Option<String>,
109 ) -> Result<Uuid> {
110 if self.metadata.find_stack_by_name(&name).is_some() {
112 return Err(CascadeError::config(format!(
113 "Stack '{name}' already exists"
114 )));
115 }
116
117 let base_branch = base_branch.unwrap_or_else(|| {
119 if let Ok(Some(detected_parent)) = self.repo.detect_parent_branch() {
121 detected_parent
122 } else {
123 self.metadata.default_base_branch.clone()
125 }
126 });
127
128 if !self.repo.branch_exists_or_fetch(&base_branch)? {
130 return Err(CascadeError::branch(format!(
131 "Base branch '{base_branch}' does not exist locally or remotely"
132 )));
133 }
134
135 let current_branch = self.repo.get_current_branch().ok();
137
138 let mut stack = Stack::new(name.clone(), base_branch.clone(), description.clone());
140
141 if let Some(ref branch) = current_branch {
143 if branch != &base_branch {
144 stack.working_branch = Some(branch.clone());
145 }
146 }
147
148 let stack_id = stack.id;
149
150 let stack_metadata = StackMetadata::new(stack_id, name, base_branch, description);
152
153 self.stacks.insert(stack_id, stack);
155 self.metadata.add_stack(stack_metadata);
156
157 self.set_active_stack(Some(stack_id))?;
159
160 Ok(stack_id)
161 }
162
163 pub fn get_stack(&self, stack_id: &Uuid) -> Option<&Stack> {
165 self.stacks.get(stack_id)
166 }
167
168 pub fn get_stack_mut(&mut self, stack_id: &Uuid) -> Option<&mut Stack> {
170 self.stacks.get_mut(stack_id)
171 }
172
173 pub fn get_stack_by_name(&self, name: &str) -> Option<&Stack> {
175 if let Some(metadata) = self.metadata.find_stack_by_name(name) {
176 self.stacks.get(&metadata.stack_id)
177 } else {
178 None
179 }
180 }
181
182 pub fn get_stack_by_name_mut(&mut self, name: &str) -> Option<&mut Stack> {
184 if let Some(metadata) = self.metadata.find_stack_by_name(name) {
185 self.stacks.get_mut(&metadata.stack_id)
186 } else {
187 None
188 }
189 }
190
191 pub fn update_stack_working_branch(&mut self, name: &str, branch: String) -> Result<()> {
193 if let Some(stack) = self.get_stack_by_name_mut(name) {
194 stack.working_branch = Some(branch);
195 self.save_to_disk()?;
196 Ok(())
197 } else {
198 Err(CascadeError::config(format!("Stack '{name}' not found")))
199 }
200 }
201
202 pub fn get_active_stack(&self) -> Option<&Stack> {
204 self.metadata
205 .active_stack_id
206 .and_then(|id| self.stacks.get(&id))
207 }
208
209 pub fn get_active_stack_mut(&mut self) -> Option<&mut Stack> {
211 if let Some(id) = self.metadata.active_stack_id {
212 self.stacks.get_mut(&id)
213 } else {
214 None
215 }
216 }
217
218 pub fn set_active_stack(&mut self, stack_id: Option<Uuid>) -> Result<()> {
220 if let Some(id) = stack_id {
222 if !self.stacks.contains_key(&id) {
223 return Err(CascadeError::config(format!(
224 "Stack with ID {id} not found"
225 )));
226 }
227 }
228
229 if self.is_in_edit_mode() {
232 if let Some(edit_info) = self.get_edit_mode_info() {
233 if edit_info.target_stack_id != stack_id {
235 tracing::debug!(
236 "Clearing edit mode when switching from stack {:?} to {:?}",
237 edit_info.target_stack_id,
238 stack_id
239 );
240 self.exit_edit_mode()?;
241 }
242 }
243 }
244
245 for stack in self.stacks.values_mut() {
247 stack.set_active(Some(stack.id) == stack_id);
248 }
249
250 if let Some(id) = stack_id {
252 let current_branch = self.repo.get_current_branch().ok();
253 if let Some(stack_meta) = self.metadata.get_stack_mut(&id) {
254 stack_meta.set_current_branch(current_branch);
255 }
256 }
257
258 self.metadata.set_active_stack(stack_id);
259 self.save_to_disk()?;
260
261 Ok(())
262 }
263
264 pub fn set_active_stack_by_name(&mut self, name: &str) -> Result<()> {
266 if let Some(metadata) = self.metadata.find_stack_by_name(name) {
267 self.set_active_stack(Some(metadata.stack_id))
268 } else {
269 Err(CascadeError::config(format!("Stack '{name}' not found")))
270 }
271 }
272
273 pub fn delete_stack(&mut self, stack_id: &Uuid) -> Result<Stack> {
275 let stack = self
276 .stacks
277 .remove(stack_id)
278 .ok_or_else(|| CascadeError::config(format!("Stack with ID {stack_id} not found")))?;
279
280 self.metadata.remove_stack(stack_id);
282
283 let stack_commits: Vec<String> = self
285 .metadata
286 .commits
287 .values()
288 .filter(|commit| &commit.stack_id == stack_id)
289 .map(|commit| commit.hash.clone())
290 .collect();
291
292 for commit_hash in stack_commits {
293 self.metadata.remove_commit(&commit_hash);
294 }
295
296 if self.metadata.active_stack_id == Some(*stack_id) {
298 let new_active = self.metadata.stacks.keys().next().copied();
299 self.set_active_stack(new_active)?;
300 }
301
302 self.save_to_disk()?;
303
304 Ok(stack)
305 }
306
307 pub fn push_to_stack(
309 &mut self,
310 branch: String,
311 commit_hash: String,
312 message: String,
313 source_branch: String,
314 ) -> Result<Uuid> {
315 let stack_id = self
316 .metadata
317 .active_stack_id
318 .ok_or_else(|| CascadeError::config("No active stack"))?;
319
320 let mut reconciled = false;
323 {
324 let stack = self
325 .stacks
326 .get_mut(&stack_id)
327 .ok_or_else(|| CascadeError::config("Active stack not found"))?;
328
329 if !stack.entries.is_empty() {
330 let mut updates = Vec::new();
332 for entry in &stack.entries {
333 if let Ok(current_commit) = self.repo.get_branch_head(&entry.branch) {
334 if entry.commit_hash != current_commit {
335 debug!(
336 "Reconciling stale metadata for '{}': updating hash from {} to {} (current branch HEAD)",
337 entry.branch,
338 &entry.commit_hash[..8],
339 ¤t_commit[..8]
340 );
341 updates.push((entry.id, current_commit));
342 }
343 }
344 }
345
346 for (entry_id, new_hash) in updates {
348 stack
349 .update_entry_commit_hash(&entry_id, new_hash)
350 .map_err(CascadeError::config)?;
351 reconciled = true;
352 }
353 }
354 } if reconciled {
358 debug!("Saving reconciled metadata before validation");
359 self.save_to_disk()?;
360 }
361
362 let stack = self
364 .stacks
365 .get_mut(&stack_id)
366 .ok_or_else(|| CascadeError::config("Active stack not found"))?;
367
368 if !stack.entries.is_empty() {
369 if let Err(integrity_error) = stack.validate_git_integrity(&self.repo) {
370 return Err(CascadeError::validation(format!(
371 "Git integrity validation failed:\n{}\n\n\
372 Fix the stack integrity issues first using 'ca stack validate {}' for details.",
373 integrity_error, stack.name
374 )));
375 }
376 }
377
378 if !self.repo.commit_exists(&commit_hash)? {
380 return Err(CascadeError::branch(format!(
381 "Commit {commit_hash} does not exist"
382 )));
383 }
384
385 let message_trimmed = message.trim();
388 if let Some(duplicate_entry) = stack
389 .entries
390 .iter()
391 .find(|entry| entry.message.trim() == message_trimmed)
392 {
393 return Err(CascadeError::validation(format!(
394 "Duplicate commit message in stack: \"{}\"\n\n\
395 This message already exists in entry {} (commit: {})\n\n\
396 š” Consider using a more specific message:\n\
397 ⢠Add context: \"{} - add validation\"\n\
398 ⢠Be more specific: \"Fix user authentication timeout bug\"\n\
399 ⢠Or amend the previous commit: git commit --amend",
400 message_trimmed,
401 duplicate_entry.id,
402 &duplicate_entry.commit_hash[..8],
403 message_trimmed
404 )));
405 }
406
407 if stack.entries.is_empty() {
412 let current_branch = self.repo.get_current_branch()?;
413
414 if stack.working_branch.is_none() && current_branch != stack.base_branch {
416 stack.working_branch = Some(current_branch.clone());
417 tracing::debug!(
418 "Set working branch for stack '{}' to '{}'",
419 stack.name,
420 current_branch
421 );
422 }
423
424 if current_branch != stack.base_branch && current_branch != "HEAD" {
425 let base_exists = self.repo.branch_exists(&stack.base_branch);
427 let current_is_feature = current_branch.starts_with("feature/")
428 || current_branch.starts_with("fix/")
429 || current_branch.starts_with("chore/")
430 || current_branch.contains("feature")
431 || current_branch.contains("fix");
432
433 if base_exists && current_is_feature {
434 tracing::debug!(
435 "First commit detected: updating stack '{}' base branch from '{}' to '{}'",
436 stack.name,
437 stack.base_branch,
438 current_branch
439 );
440
441 Output::info("Smart Base Branch Update:");
442 Output::sub_item(format!(
443 "Stack '{}' was created with base '{}'",
444 stack.name, stack.base_branch
445 ));
446 Output::sub_item(format!(
447 "You're now working on feature branch '{current_branch}'"
448 ));
449 Output::sub_item("Updating stack base branch to match your workflow");
450
451 stack.base_branch = current_branch.clone();
453
454 if let Some(stack_meta) = self.metadata.get_stack_mut(&stack_id) {
456 stack_meta.base_branch = current_branch.clone();
457 stack_meta.set_current_branch(Some(current_branch.clone()));
458 }
459
460 println!(
461 " ā
Stack '{}' base branch updated to '{current_branch}'",
462 stack.name
463 );
464 }
465 }
466 }
467
468 if self.repo.branch_exists(&branch) {
471 self.repo
475 .update_branch_to_commit(&branch, &commit_hash)
476 .map_err(|e| {
477 CascadeError::branch(format!(
478 "Failed to update existing branch '{}' to commit {}: {}",
479 branch,
480 &commit_hash[..8],
481 e
482 ))
483 })?;
484 } else {
485 self.repo
487 .create_branch(&branch, Some(&commit_hash))
488 .map_err(|e| {
489 CascadeError::branch(format!(
490 "Failed to create branch '{}' from commit {}: {}",
491 branch,
492 &commit_hash[..8],
493 e
494 ))
495 })?;
496
497 }
499
500 let entry_id = stack.push_entry(branch.clone(), commit_hash.clone(), message.clone());
502
503 let commit_metadata = CommitMetadata::new(
505 commit_hash.clone(),
506 message,
507 entry_id,
508 stack_id,
509 branch.clone(),
510 source_branch,
511 );
512
513 self.metadata.add_commit(commit_metadata);
515 if let Some(stack_meta) = self.metadata.get_stack_mut(&stack_id) {
516 stack_meta.add_branch(branch);
517 stack_meta.add_commit(commit_hash);
518 }
519
520 self.save_to_disk()?;
521
522 Ok(entry_id)
523 }
524
525 pub fn pop_from_stack(&mut self) -> Result<StackEntry> {
527 let stack_id = self
528 .metadata
529 .active_stack_id
530 .ok_or_else(|| CascadeError::config("No active stack"))?;
531
532 let stack = self
533 .stacks
534 .get_mut(&stack_id)
535 .ok_or_else(|| CascadeError::config("Active stack not found"))?;
536
537 let entry = stack
538 .pop_entry()
539 .ok_or_else(|| CascadeError::config("Stack is empty"))?;
540
541 self.metadata.remove_commit(&entry.commit_hash);
543
544 if let Some(stack_meta) = self.metadata.get_stack_mut(&stack_id) {
546 stack_meta.remove_commit(&entry.commit_hash);
547 }
549
550 self.save_to_disk()?;
551
552 Ok(entry)
553 }
554
555 pub fn submit_entry(
557 &mut self,
558 stack_id: &Uuid,
559 entry_id: &Uuid,
560 pull_request_id: String,
561 ) -> Result<()> {
562 let stack = self
563 .stacks
564 .get_mut(stack_id)
565 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
566
567 let entry_commit_hash = {
568 let entry = stack
569 .get_entry(entry_id)
570 .ok_or_else(|| CascadeError::config(format!("Entry {entry_id} not found")))?;
571 entry.commit_hash.clone()
572 };
573
574 if !stack.mark_entry_submitted(entry_id, pull_request_id.clone()) {
576 return Err(CascadeError::config(format!(
577 "Failed to mark entry {entry_id} as submitted"
578 )));
579 }
580
581 if let Some(commit_meta) = self.metadata.commits.get_mut(&entry_commit_hash) {
583 commit_meta.mark_submitted(pull_request_id);
584 }
585
586 if let Some(stack_meta) = self.metadata.get_stack_mut(stack_id) {
588 let submitted_count = stack.entries.iter().filter(|e| e.is_submitted).count();
589 let merged_count = stack.entries.iter().filter(|e| e.is_merged).count();
590 stack_meta.update_stats(stack.entries.len(), submitted_count, merged_count);
591 }
592
593 self.save_to_disk()?;
594
595 Ok(())
596 }
597
598 pub fn remove_stack_entry(
600 &mut self,
601 stack_id: &Uuid,
602 entry_id: &Uuid,
603 ) -> Result<Option<StackEntry>> {
604 let stack = match self.stacks.get_mut(stack_id) {
605 Some(stack) => stack,
606 None => return Err(CascadeError::config(format!("Stack {stack_id} not found"))),
607 };
608
609 let entry = match stack.entry_map.get(entry_id) {
610 Some(entry) => entry.clone(),
611 None => return Ok(None),
612 };
613
614 if !entry.children.is_empty() {
615 warn!(
616 "Skipping removal of stack entry {} (branch '{}') because it still has {} child entr{}",
617 entry.id,
618 entry.branch,
619 entry.children.len(),
620 if entry.children.len() == 1 { "y" } else { "ies" }
621 );
622 return Ok(None);
623 }
624
625 stack.entries.retain(|e| e.id != entry.id);
627
628 stack.entry_map.remove(&entry.id);
630
631 if let Some(parent_id) = entry.parent_id {
633 if let Some(parent) = stack.entry_map.get_mut(&parent_id) {
634 parent.children.retain(|child| child != &entry.id);
635 }
636 }
637
638 stack.repair_data_consistency();
640 stack.updated_at = Utc::now();
641
642 self.metadata.remove_commit(&entry.commit_hash);
644 if let Some(stack_meta) = self.metadata.get_stack_mut(stack_id) {
645 stack_meta.remove_commit(&entry.commit_hash);
646 stack_meta.remove_branch(&entry.branch);
647
648 let submitted = stack.entries.iter().filter(|e| e.is_submitted).count();
649 let merged = stack.entries.iter().filter(|e| e.is_merged).count();
650 stack_meta.update_stats(stack.entries.len(), submitted, merged);
651 }
652
653 self.save_to_disk()?;
654
655 Ok(Some(entry))
656 }
657
658 pub fn remove_stack_entry_at(
660 &mut self,
661 stack_id: &Uuid,
662 index: usize,
663 ) -> Result<Option<StackEntry>> {
664 let stack = match self.stacks.get_mut(stack_id) {
665 Some(stack) => stack,
666 None => return Err(CascadeError::config(format!("Stack {stack_id} not found"))),
667 };
668
669 let entry = match stack.remove_entry_at(index) {
670 Some(entry) => entry,
671 None => return Ok(None),
672 };
673
674 self.metadata.remove_commit(&entry.commit_hash);
676 if let Some(stack_meta) = self.metadata.get_stack_mut(stack_id) {
677 stack_meta.remove_commit(&entry.commit_hash);
678 stack_meta.remove_branch(&entry.branch);
679
680 let submitted = stack.entries.iter().filter(|e| e.is_submitted).count();
681 let merged = stack.entries.iter().filter(|e| e.is_merged).count();
682 stack_meta.update_stats(stack.entries.len(), submitted, merged);
683 }
684
685 self.save_to_disk()?;
686
687 Ok(Some(entry))
688 }
689
690 pub fn set_entry_merged(
692 &mut self,
693 stack_id: &Uuid,
694 entry_id: &Uuid,
695 merged: bool,
696 ) -> Result<()> {
697 let stack = self
698 .stacks
699 .get_mut(stack_id)
700 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
701
702 let current_entry = stack
703 .entry_map
704 .get(entry_id)
705 .cloned()
706 .ok_or_else(|| CascadeError::config(format!("Entry {entry_id} not found")))?;
707
708 if current_entry.is_merged == merged {
709 return Ok(());
710 }
711
712 if !stack.mark_entry_merged(entry_id, merged) {
713 return Err(CascadeError::config(format!(
714 "Entry {entry_id} not found in stack {stack_id}"
715 )));
716 }
717
718 if let Some(commit_meta) = self.metadata.commits.get_mut(¤t_entry.commit_hash) {
720 commit_meta.mark_merged(merged);
721 }
722
723 if let Some(stack_meta) = self.metadata.get_stack_mut(stack_id) {
725 let submitted_count = stack.entries.iter().filter(|e| e.is_submitted).count();
726 let merged_count = stack.entries.iter().filter(|e| e.is_merged).count();
727 stack_meta.update_stats(stack.entries.len(), submitted_count, merged_count);
728 }
729
730 self.save_to_disk()?;
731
732 Ok(())
733 }
734
735 pub fn repair_all_stacks(&mut self) -> Result<()> {
737 for stack in self.stacks.values_mut() {
738 stack.repair_data_consistency();
739 }
740 self.save_to_disk()?;
741 Ok(())
742 }
743
744 pub fn get_all_stacks(&self) -> Vec<&Stack> {
746 self.stacks.values().collect()
747 }
748
749 pub fn get_stack_metadata(&self, stack_id: &Uuid) -> Option<&StackMetadata> {
751 self.metadata.get_stack(stack_id)
752 }
753
754 pub fn get_repository_metadata(&self) -> &RepositoryMetadata {
756 &self.metadata
757 }
758
759 pub fn git_repo(&self) -> &GitRepository {
761 &self.repo
762 }
763
764 pub fn repo_path(&self) -> &Path {
766 &self.repo_path
767 }
768
769 pub fn is_in_edit_mode(&self) -> bool {
773 self.metadata
774 .edit_mode
775 .as_ref()
776 .map(|edit_state| edit_state.is_active)
777 .unwrap_or(false)
778 }
779
780 pub fn get_edit_mode_info(&self) -> Option<&super::metadata::EditModeState> {
782 self.metadata.edit_mode.as_ref()
783 }
784
785 pub fn enter_edit_mode(&mut self, stack_id: Uuid, entry_id: Uuid) -> Result<()> {
787 let commit_hash = {
789 let stack = self
790 .get_stack(&stack_id)
791 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
792
793 let entry = stack.get_entry(&entry_id).ok_or_else(|| {
794 CascadeError::config(format!("Entry {entry_id} not found in stack"))
795 })?;
796
797 entry.commit_hash.clone()
798 };
799
800 if self.is_in_edit_mode() {
802 self.exit_edit_mode()?;
803 }
804
805 let edit_state = super::metadata::EditModeState::new(stack_id, entry_id, commit_hash);
807
808 self.metadata.edit_mode = Some(edit_state);
809 self.save_to_disk()?;
810
811 debug!(
812 "Entered edit mode for entry {} in stack {}",
813 entry_id, stack_id
814 );
815 Ok(())
816 }
817
818 pub fn exit_edit_mode(&mut self) -> Result<()> {
820 if !self.is_in_edit_mode() {
821 return Err(CascadeError::config("Not currently in edit mode"));
822 }
823
824 self.metadata.edit_mode = None;
826 self.save_to_disk()?;
827
828 debug!("Exited edit mode");
829 Ok(())
830 }
831
832 pub fn sync_stack(&mut self, stack_id: &Uuid) -> Result<()> {
834 let stack = self
835 .stacks
836 .get_mut(stack_id)
837 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
838
839 if let Err(integrity_error) = stack.validate_git_integrity(&self.repo) {
841 stack.update_status(StackStatus::Corrupted);
842 return Err(CascadeError::branch(format!(
843 "Stack '{}' Git integrity check failed:\n{}",
844 stack.name, integrity_error
845 )));
846 }
847
848 let mut missing_commits = Vec::new();
850 for entry in &stack.entries {
851 if !self.repo.commit_exists(&entry.commit_hash)? {
852 missing_commits.push(entry.commit_hash.clone());
853 }
854 }
855
856 if !missing_commits.is_empty() {
857 stack.update_status(StackStatus::Corrupted);
858 return Err(CascadeError::branch(format!(
859 "Stack {} has missing commits: {}",
860 stack.name,
861 missing_commits.join(", ")
862 )));
863 }
864
865 if !self.repo.branch_exists_or_fetch(&stack.base_branch)? {
867 return Err(CascadeError::branch(format!(
868 "Base branch '{}' does not exist locally or remotely. Check the branch name or switch to a different base.",
869 stack.base_branch
870 )));
871 }
872
873 let _base_hash = self.repo.get_branch_head(&stack.base_branch)?;
874
875 let mut corrupted_entry = None;
877 for entry in &stack.entries {
878 if !self.repo.commit_exists(&entry.commit_hash)? {
879 corrupted_entry = Some((entry.commit_hash.clone(), entry.branch.clone()));
880 break;
881 }
882 }
883
884 if let Some((commit_hash, branch)) = corrupted_entry {
885 stack.update_status(StackStatus::Corrupted);
886 return Err(CascadeError::branch(format!(
887 "Commit {commit_hash} from stack entry '{branch}' no longer exists"
888 )));
889 }
890
891 let needs_sync = if let Some(first_entry) = stack.entries.first() {
893 match self
895 .repo
896 .get_commits_between(&stack.base_branch, &first_entry.commit_hash)
897 {
898 Ok(commits) => !commits.is_empty(), Err(_) => true, }
901 } else {
902 false };
904
905 if needs_sync {
907 stack.update_status(StackStatus::NeedsSync);
908 debug!(
909 "Stack '{}' needs sync - new commits on base branch",
910 stack.name
911 );
912 } else {
913 stack.update_status(StackStatus::Clean);
914 debug!("Stack '{}' is clean", stack.name);
915 }
916
917 if let Some(stack_meta) = self.metadata.get_stack_mut(stack_id) {
919 stack_meta.set_up_to_date(true);
920 }
921
922 self.save_to_disk()?;
923
924 Ok(())
925 }
926
927 pub fn list_stacks(&self) -> Vec<(Uuid, &str, &StackStatus, usize, Option<&str>)> {
929 self.stacks
930 .values()
931 .map(|stack| {
932 (
933 stack.id,
934 stack.name.as_str(),
935 &stack.status,
936 stack.entries.len(),
937 if stack.is_active {
938 Some("active")
939 } else {
940 None
941 },
942 )
943 })
944 .collect()
945 }
946
947 pub fn get_all_stacks_objects(&self) -> Result<Vec<Stack>> {
949 let mut stacks: Vec<Stack> = self.stacks.values().cloned().collect();
950 stacks.sort_by(|a, b| a.name.cmp(&b.name));
951 Ok(stacks)
952 }
953
954 pub fn validate_all(&self) -> Result<()> {
956 for stack in self.stacks.values() {
957 stack.validate().map_err(|e| {
959 CascadeError::config(format!("Stack '{}' validation failed: {}", stack.name, e))
960 })?;
961
962 stack.validate_git_integrity(&self.repo).map_err(|e| {
964 CascadeError::config(format!(
965 "Stack '{}' Git integrity validation failed: {}",
966 stack.name, e
967 ))
968 })?;
969 }
970 Ok(())
971 }
972
973 pub fn validate_stack(&self, stack_id: &Uuid) -> Result<()> {
975 let stack = self
976 .stacks
977 .get(stack_id)
978 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
979
980 stack.validate().map_err(|e| {
982 CascadeError::config(format!("Stack '{}' validation failed: {}", stack.name, e))
983 })?;
984
985 stack.validate_git_integrity(&self.repo).map_err(|e| {
987 CascadeError::config(format!(
988 "Stack '{}' Git integrity validation failed: {}",
989 stack.name, e
990 ))
991 })?;
992
993 Ok(())
994 }
995
996 pub fn save_to_disk(&self) -> Result<()> {
998 if !self.config_dir.exists() {
1000 fs::create_dir_all(&self.config_dir).map_err(|e| {
1001 CascadeError::config(format!("Failed to create config directory: {e}"))
1002 })?;
1003 }
1004
1005 crate::utils::atomic_file::write_json(&self.stacks_file, &self.stacks)?;
1007
1008 crate::utils::atomic_file::write_json(&self.metadata_file, &self.metadata)?;
1010
1011 Ok(())
1012 }
1013
1014 fn load_from_disk(&mut self) -> Result<()> {
1016 if self.stacks_file.exists() {
1018 let stacks_content = fs::read_to_string(&self.stacks_file)
1019 .map_err(|e| CascadeError::config(format!("Failed to read stacks file: {e}")))?;
1020
1021 self.stacks = serde_json::from_str(&stacks_content)
1022 .map_err(|e| CascadeError::config(format!("Failed to parse stacks file: {e}")))?;
1023 }
1024
1025 if self.metadata_file.exists() {
1027 let metadata_content = fs::read_to_string(&self.metadata_file)
1028 .map_err(|e| CascadeError::config(format!("Failed to read metadata file: {e}")))?;
1029
1030 self.metadata = serde_json::from_str(&metadata_content)
1031 .map_err(|e| CascadeError::config(format!("Failed to parse metadata file: {e}")))?;
1032 }
1033
1034 Ok(())
1035 }
1036
1037 pub fn check_for_branch_change(&mut self) -> Result<bool> {
1040 let (stack_id, stack_name, stored_branch) = {
1042 if let Some(active_stack) = self.get_active_stack() {
1043 let stack_id = active_stack.id;
1044 let stack_name = active_stack.name.clone();
1045 let stored_branch = if let Some(stack_meta) = self.metadata.get_stack(&stack_id) {
1046 stack_meta.current_branch.clone()
1047 } else {
1048 None
1049 };
1050 (Some(stack_id), stack_name, stored_branch)
1051 } else {
1052 (None, String::new(), None)
1053 }
1054 };
1055
1056 let Some(stack_id) = stack_id else {
1058 return Ok(true);
1059 };
1060
1061 let current_branch = self.repo.get_current_branch().ok();
1062
1063 if stored_branch.as_ref() != current_branch.as_ref() {
1065 Output::warning("Branch change detected!");
1066 Output::sub_item(format!(
1067 "Stack '{}' was active on: {}",
1068 stack_name,
1069 stored_branch.as_deref().unwrap_or("unknown")
1070 ));
1071 Output::sub_item(format!(
1072 "Current branch: {}",
1073 current_branch.as_deref().unwrap_or("unknown")
1074 ));
1075 Output::spacing();
1076
1077 let options = vec![
1078 format!("Keep stack '{stack_name}' active (continue with stack workflow)"),
1079 "Deactivate stack (use normal Git workflow)".to_string(),
1080 "Switch to a different stack".to_string(),
1081 "Cancel and stay on current workflow".to_string(),
1082 ];
1083
1084 let choice = Select::with_theme(&ColorfulTheme::default())
1085 .with_prompt("What would you like to do?")
1086 .default(0)
1087 .items(&options)
1088 .interact()
1089 .map_err(|e| CascadeError::config(format!("Failed to get user choice: {e}")))?;
1090
1091 match choice {
1092 0 => {
1093 if let Some(stack_meta) = self.metadata.get_stack_mut(&stack_id) {
1095 stack_meta.set_current_branch(current_branch);
1096 }
1097 self.save_to_disk()?;
1098 Output::success(format!(
1099 "Continuing with stack '{stack_name}' on current branch"
1100 ));
1101 return Ok(true);
1102 }
1103 1 => {
1104 self.set_active_stack(None)?;
1106 Output::success(format!(
1107 "Deactivated stack '{stack_name}' - using normal Git workflow"
1108 ));
1109 return Ok(false);
1110 }
1111 2 => {
1112 let stacks = self.get_all_stacks();
1114 if stacks.len() <= 1 {
1115 Output::warning("No other stacks available. Deactivating current stack.");
1116 self.set_active_stack(None)?;
1117 return Ok(false);
1118 }
1119
1120 Output::spacing();
1121 Output::info("Available stacks:");
1122 for (i, stack) in stacks.iter().enumerate() {
1123 if stack.id != stack_id {
1124 Output::numbered_item(i + 1, &stack.name);
1125 }
1126 }
1127 let stack_name_input: String = Input::with_theme(&ColorfulTheme::default())
1128 .with_prompt("Enter stack name")
1129 .validate_with(|input: &String| -> std::result::Result<(), &str> {
1130 if input.trim().is_empty() {
1131 Err("Stack name cannot be empty")
1132 } else {
1133 Ok(())
1134 }
1135 })
1136 .interact_text()
1137 .map_err(|e| {
1138 CascadeError::config(format!("Failed to get user input: {e}"))
1139 })?;
1140 let stack_name_input = stack_name_input.trim();
1141
1142 if let Err(e) = self.set_active_stack_by_name(stack_name_input) {
1143 Output::warning(format!("{e}"));
1144 Output::sub_item("Deactivating stack instead.");
1145 self.set_active_stack(None)?;
1146 return Ok(false);
1147 } else {
1148 Output::success(format!("Switched to stack '{stack_name_input}'"));
1149 return Ok(true);
1150 }
1151 }
1152 3 => {
1153 Output::info("Cancelled - no changes made");
1154 return Ok(false);
1155 }
1156 _ => {
1157 Output::info("Invalid choice - no changes made");
1158 return Ok(false);
1159 }
1160 }
1161 }
1162
1163 Ok(true)
1165 }
1166
1167 pub fn handle_branch_modifications(
1170 &mut self,
1171 stack_id: &Uuid,
1172 auto_mode: Option<String>,
1173 ) -> Result<()> {
1174 let stack = self
1175 .stacks
1176 .get_mut(stack_id)
1177 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
1178
1179 debug!("Checking Git integrity for stack '{}'", stack.name);
1180
1181 let mut modifications = Vec::new();
1183 for entry in &stack.entries {
1184 if !self.repo.branch_exists(&entry.branch) {
1185 modifications.push(BranchModification::Missing {
1186 branch: entry.branch.clone(),
1187 entry_id: entry.id,
1188 expected_commit: entry.commit_hash.clone(),
1189 });
1190 } else if let Ok(branch_head) = self.repo.get_branch_head(&entry.branch) {
1191 if branch_head != entry.commit_hash {
1192 let extra_commits = self
1194 .repo
1195 .get_commits_between(&entry.commit_hash, &branch_head)?;
1196 let mut extra_messages = Vec::new();
1197 for commit in &extra_commits {
1198 if let Some(message) = commit.message() {
1199 let first_line =
1200 message.lines().next().unwrap_or("(no message)").to_string();
1201 extra_messages.push(format!(
1202 "{}: {}",
1203 &commit.id().to_string()[..8],
1204 first_line
1205 ));
1206 }
1207 }
1208
1209 modifications.push(BranchModification::ExtraCommits {
1210 branch: entry.branch.clone(),
1211 entry_id: entry.id,
1212 expected_commit: entry.commit_hash.clone(),
1213 actual_commit: branch_head,
1214 extra_commit_count: extra_commits.len(),
1215 extra_commit_messages: extra_messages,
1216 });
1217 }
1218 }
1219 }
1220
1221 if modifications.is_empty() {
1222 return Ok(());
1224 }
1225
1226 println!();
1228 Output::section(format!("Branch modifications detected in '{}'", stack.name));
1229 for (i, modification) in modifications.iter().enumerate() {
1230 match modification {
1231 BranchModification::Missing { branch, .. } => {
1232 Output::numbered_item(i + 1, format!("Branch '{branch}' is missing"));
1233 }
1234 BranchModification::ExtraCommits {
1235 branch,
1236 expected_commit,
1237 actual_commit,
1238 extra_commit_count,
1239 extra_commit_messages,
1240 ..
1241 } => {
1242 println!(
1243 " {}. Branch '{}' has {} extra commit(s)",
1244 i + 1,
1245 branch,
1246 extra_commit_count
1247 );
1248 println!(
1249 " Expected: {} | Actual: {}",
1250 &expected_commit[..8],
1251 &actual_commit[..8]
1252 );
1253
1254 for (j, message) in extra_commit_messages.iter().enumerate() {
1256 match j.cmp(&3) {
1257 std::cmp::Ordering::Less => {
1258 Output::sub_item(format!(" + {message}"));
1259 }
1260 std::cmp::Ordering::Equal => {
1261 Output::sub_item(format!(
1262 " + ... and {} more",
1263 extra_commit_count - 3
1264 ));
1265 break;
1266 }
1267 std::cmp::Ordering::Greater => {
1268 break;
1269 }
1270 }
1271 }
1272 }
1273 }
1274 }
1275 Output::spacing();
1276
1277 if let Some(mode) = auto_mode {
1279 return self.apply_auto_fix(stack_id, &modifications, &mode);
1280 }
1281
1282 let mut handled_count = 0;
1284 let mut skipped_count = 0;
1285 for modification in modifications.iter() {
1286 let was_skipped = self.handle_single_modification(stack_id, modification)?;
1287 if was_skipped {
1288 skipped_count += 1;
1289 } else {
1290 handled_count += 1;
1291 }
1292 }
1293
1294 self.save_to_disk()?;
1295
1296 if skipped_count == 0 {
1298 Output::success("All branch modifications resolved");
1299 } else if handled_count > 0 {
1300 Output::warning(format!(
1301 "Resolved {} modification(s), {} skipped",
1302 handled_count, skipped_count
1303 ));
1304 } else {
1305 Output::warning("All modifications skipped - integrity issues remain");
1306 }
1307
1308 Ok(())
1309 }
1310
1311 fn handle_single_modification(
1314 &mut self,
1315 stack_id: &Uuid,
1316 modification: &BranchModification,
1317 ) -> Result<bool> {
1318 match modification {
1319 BranchModification::Missing {
1320 branch,
1321 expected_commit,
1322 ..
1323 } => {
1324 Output::info(format!("Missing branch '{branch}'"));
1325 Output::sub_item(format!(
1326 "Will create the branch at commit {}",
1327 &expected_commit[..8]
1328 ));
1329
1330 self.repo.create_branch(branch, Some(expected_commit))?;
1331 Output::success(format!("Created branch '{branch}'"));
1332 Ok(false) }
1334
1335 BranchModification::ExtraCommits {
1336 branch,
1337 entry_id,
1338 expected_commit,
1339 extra_commit_count,
1340 ..
1341 } => {
1342 println!();
1343 Output::info(format!(
1344 "Branch '{}' has {} extra commit(s)",
1345 branch, extra_commit_count
1346 ));
1347 let options = vec![
1348 "Incorporate - Update stack entry to include extra commits",
1349 "Split - Create new stack entry for extra commits",
1350 "Reset - Remove extra commits (DESTRUCTIVE)",
1351 "Skip - Leave as-is for now",
1352 ];
1353
1354 let choice = Select::with_theme(&ColorfulTheme::default())
1355 .with_prompt("Choose how to handle extra commits")
1356 .default(0)
1357 .items(&options)
1358 .interact()
1359 .map_err(|e| CascadeError::config(format!("Failed to get user choice: {e}")))?;
1360
1361 match choice {
1362 0 => {
1363 self.incorporate_extra_commits(stack_id, *entry_id, branch)?;
1364 Ok(false) }
1366 1 => {
1367 self.split_extra_commits(stack_id, *entry_id, branch)?;
1368 Ok(false) }
1370 2 => {
1371 self.reset_branch_destructive(branch, expected_commit)?;
1372 Ok(false) }
1374 3 => {
1375 Output::warning(format!("Skipped '{branch}' - integrity issue remains"));
1376 Ok(true) }
1378 _ => {
1379 Output::warning(format!("Invalid choice - skipped '{branch}'"));
1380 Ok(true) }
1382 }
1383 }
1384 }
1385 }
1386
1387 fn apply_auto_fix(
1389 &mut self,
1390 stack_id: &Uuid,
1391 modifications: &[BranchModification],
1392 mode: &str,
1393 ) -> Result<()> {
1394 Output::info(format!("š¤ Applying automatic fix mode: {mode}"));
1395
1396 for modification in modifications {
1397 match (modification, mode) {
1398 (
1399 BranchModification::Missing {
1400 branch,
1401 expected_commit,
1402 ..
1403 },
1404 _,
1405 ) => {
1406 self.repo.create_branch(branch, Some(expected_commit))?;
1407 Output::success(format!("Created missing branch '{branch}'"));
1408 }
1409
1410 (
1411 BranchModification::ExtraCommits {
1412 branch, entry_id, ..
1413 },
1414 "incorporate",
1415 ) => {
1416 self.incorporate_extra_commits(stack_id, *entry_id, branch)?;
1417 }
1418
1419 (
1420 BranchModification::ExtraCommits {
1421 branch, entry_id, ..
1422 },
1423 "split",
1424 ) => {
1425 self.split_extra_commits(stack_id, *entry_id, branch)?;
1426 }
1427
1428 (
1429 BranchModification::ExtraCommits {
1430 branch,
1431 expected_commit,
1432 ..
1433 },
1434 "reset",
1435 ) => {
1436 self.reset_branch_destructive(branch, expected_commit)?;
1437 }
1438
1439 _ => {
1440 return Err(CascadeError::config(format!(
1441 "Unknown auto-fix mode '{mode}'. Use: incorporate, split, reset"
1442 )));
1443 }
1444 }
1445 }
1446
1447 self.save_to_disk()?;
1448 Output::success(format!("Auto-fix completed for mode: {mode}"));
1449 Ok(())
1450 }
1451
1452 fn incorporate_extra_commits(
1454 &mut self,
1455 stack_id: &Uuid,
1456 entry_id: Uuid,
1457 branch: &str,
1458 ) -> Result<()> {
1459 let stack = self
1460 .stacks
1461 .get_mut(stack_id)
1462 .ok_or_else(|| CascadeError::config(format!("Stack not found: {}", stack_id)))?;
1463
1464 let entry_info = stack
1466 .entries
1467 .iter()
1468 .find(|e| e.id == entry_id)
1469 .map(|e| (e.commit_hash.clone(), e.id));
1470
1471 if let Some((old_commit_hash, entry_id)) = entry_info {
1472 let new_head = self.repo.get_branch_head(branch)?;
1473 let old_commit = old_commit_hash[..8].to_string();
1474
1475 let extra_commits = self.repo.get_commits_between(&old_commit_hash, &new_head)?;
1477
1478 stack
1482 .update_entry_commit_hash(&entry_id, new_head.clone())
1483 .map_err(CascadeError::config)?;
1484
1485 Output::success(format!(
1486 "Incorporated {} commit(s) into entry '{}'",
1487 extra_commits.len(),
1488 &new_head[..8]
1489 ));
1490 Output::sub_item(format!("Updated: {} -> {}", old_commit, &new_head[..8]));
1491 }
1492
1493 Ok(())
1494 }
1495
1496 fn split_extra_commits(&mut self, stack_id: &Uuid, entry_id: Uuid, branch: &str) -> Result<()> {
1498 let stack = self
1499 .stacks
1500 .get_mut(stack_id)
1501 .ok_or_else(|| CascadeError::config(format!("Stack not found: {}", stack_id)))?;
1502 let new_head = self.repo.get_branch_head(branch)?;
1503
1504 let entry_position = stack
1506 .entries
1507 .iter()
1508 .position(|e| e.id == entry_id)
1509 .ok_or_else(|| CascadeError::config("Entry not found in stack"))?;
1510
1511 let base_name = branch.trim_end_matches(|c: char| c.is_ascii_digit() || c == '-');
1513 let new_branch = format!("{base_name}-continued");
1514
1515 self.repo.create_branch(&new_branch, Some(&new_head))?;
1517
1518 let original_entry = &stack.entries[entry_position];
1520 let original_commit_hash = original_entry.commit_hash.clone(); let extra_commits = self
1522 .repo
1523 .get_commits_between(&original_commit_hash, &new_head)?;
1524
1525 let mut extra_messages = Vec::new();
1527 for commit in &extra_commits {
1528 if let Some(message) = commit.message() {
1529 let first_line = message.lines().next().unwrap_or("").to_string();
1530 extra_messages.push(first_line);
1531 }
1532 }
1533
1534 let new_message = if extra_messages.len() == 1 {
1535 extra_messages[0].clone()
1536 } else {
1537 format!("Combined changes:\n⢠{}", extra_messages.join("\n⢠"))
1538 };
1539
1540 let now = Utc::now();
1542 let new_entry = crate::stack::StackEntry {
1543 id: uuid::Uuid::new_v4(),
1544 branch: new_branch.clone(),
1545 commit_hash: new_head,
1546 message: new_message,
1547 parent_id: Some(entry_id), children: Vec::new(),
1549 created_at: now,
1550 updated_at: now,
1551 is_submitted: false,
1552 pull_request_id: None,
1553 is_synced: false,
1554 is_merged: false,
1555 };
1556
1557 stack.entries.insert(entry_position + 1, new_entry);
1559
1560 self.repo
1562 .reset_branch_to_commit(branch, &original_commit_hash)?;
1563
1564 println!(
1565 " ā
Split {} commit(s) into new entry '{}'",
1566 extra_commits.len(),
1567 new_branch
1568 );
1569 println!(" Original branch '{branch}' reset to expected commit");
1570
1571 Ok(())
1572 }
1573
1574 fn reset_branch_destructive(&self, branch: &str, expected_commit: &str) -> Result<()> {
1576 self.repo.reset_branch_to_commit(branch, expected_commit)?;
1577 Output::warning(format!(
1578 "Reset branch '{}' to {} (extra commits lost)",
1579 branch,
1580 &expected_commit[..8]
1581 ));
1582 Ok(())
1583 }
1584}
1585
1586#[cfg(test)]
1587mod tests {
1588 use super::*;
1589 use std::process::Command;
1590 use tempfile::TempDir;
1591
1592 fn create_test_repo() -> (TempDir, PathBuf) {
1593 let temp_dir = TempDir::new().unwrap();
1594 let repo_path = temp_dir.path().to_path_buf();
1595
1596 Command::new("git")
1598 .args(["init"])
1599 .current_dir(&repo_path)
1600 .output()
1601 .unwrap();
1602
1603 Command::new("git")
1605 .args(["config", "user.name", "Test User"])
1606 .current_dir(&repo_path)
1607 .output()
1608 .unwrap();
1609
1610 Command::new("git")
1611 .args(["config", "user.email", "test@example.com"])
1612 .current_dir(&repo_path)
1613 .output()
1614 .unwrap();
1615
1616 std::fs::write(repo_path.join("README.md"), "# Test Repo").unwrap();
1618 Command::new("git")
1619 .args(["add", "."])
1620 .current_dir(&repo_path)
1621 .output()
1622 .unwrap();
1623
1624 Command::new("git")
1625 .args(["commit", "-m", "Initial commit"])
1626 .current_dir(&repo_path)
1627 .output()
1628 .unwrap();
1629
1630 crate::config::initialize_repo(&repo_path, Some("https://test.bitbucket.com".to_string()))
1632 .unwrap();
1633
1634 (temp_dir, repo_path)
1635 }
1636
1637 #[test]
1638 fn test_create_stack_manager() {
1639 let (_temp_dir, repo_path) = create_test_repo();
1640 let manager = StackManager::new(&repo_path).unwrap();
1641
1642 assert_eq!(manager.stacks.len(), 0);
1643 assert!(manager.get_active_stack().is_none());
1644 }
1645
1646 #[test]
1647 fn test_create_and_manage_stack() {
1648 let (_temp_dir, repo_path) = create_test_repo();
1649 let mut manager = StackManager::new(&repo_path).unwrap();
1650
1651 let stack_id = manager
1653 .create_stack(
1654 "test-stack".to_string(),
1655 None, Some("Test stack description".to_string()),
1657 )
1658 .unwrap();
1659
1660 assert_eq!(manager.stacks.len(), 1);
1662 let stack = manager.get_stack(&stack_id).unwrap();
1663 assert_eq!(stack.name, "test-stack");
1664 assert!(!stack.base_branch.is_empty());
1666 assert!(stack.is_active);
1667
1668 let active = manager.get_active_stack().unwrap();
1670 assert_eq!(active.id, stack_id);
1671
1672 let found = manager.get_stack_by_name("test-stack").unwrap();
1674 assert_eq!(found.id, stack_id);
1675 }
1676
1677 #[test]
1678 fn test_stack_persistence() {
1679 let (_temp_dir, repo_path) = create_test_repo();
1680
1681 let stack_id = {
1682 let mut manager = StackManager::new(&repo_path).unwrap();
1683 manager
1684 .create_stack("persistent-stack".to_string(), None, None)
1685 .unwrap()
1686 };
1687
1688 let manager = StackManager::new(&repo_path).unwrap();
1690 assert_eq!(manager.stacks.len(), 1);
1691 let stack = manager.get_stack(&stack_id).unwrap();
1692 assert_eq!(stack.name, "persistent-stack");
1693 }
1694
1695 #[test]
1696 fn test_multiple_stacks() {
1697 let (_temp_dir, repo_path) = create_test_repo();
1698 let mut manager = StackManager::new(&repo_path).unwrap();
1699
1700 let stack1_id = manager
1701 .create_stack("stack-1".to_string(), None, None)
1702 .unwrap();
1703 let stack2_id = manager
1704 .create_stack("stack-2".to_string(), None, None)
1705 .unwrap();
1706
1707 assert_eq!(manager.stacks.len(), 2);
1708
1709 assert!(!manager.get_stack(&stack1_id).unwrap().is_active);
1711 assert!(manager.get_stack(&stack2_id).unwrap().is_active);
1712
1713 manager.set_active_stack(Some(stack2_id)).unwrap();
1715 assert!(!manager.get_stack(&stack1_id).unwrap().is_active);
1716 assert!(manager.get_stack(&stack2_id).unwrap().is_active);
1717 }
1718
1719 #[test]
1720 fn test_delete_stack() {
1721 let (_temp_dir, repo_path) = create_test_repo();
1722 let mut manager = StackManager::new(&repo_path).unwrap();
1723
1724 let stack_id = manager
1725 .create_stack("to-delete".to_string(), None, None)
1726 .unwrap();
1727 assert_eq!(manager.stacks.len(), 1);
1728
1729 let deleted = manager.delete_stack(&stack_id).unwrap();
1730 assert_eq!(deleted.name, "to-delete");
1731 assert_eq!(manager.stacks.len(), 0);
1732 assert!(manager.get_active_stack().is_none());
1733 }
1734
1735 #[test]
1736 fn test_validation() {
1737 let (_temp_dir, repo_path) = create_test_repo();
1738 let mut manager = StackManager::new(&repo_path).unwrap();
1739
1740 manager
1741 .create_stack("valid-stack".to_string(), None, None)
1742 .unwrap();
1743
1744 assert!(manager.validate_all().is_ok());
1746 }
1747
1748 #[test]
1749 fn test_duplicate_commit_message_detection() {
1750 let (_temp_dir, repo_path) = create_test_repo();
1751 let mut manager = StackManager::new(&repo_path).unwrap();
1752
1753 manager
1755 .create_stack("test-stack".to_string(), None, None)
1756 .unwrap();
1757
1758 std::fs::write(repo_path.join("file1.txt"), "content1").unwrap();
1760 Command::new("git")
1761 .args(["add", "file1.txt"])
1762 .current_dir(&repo_path)
1763 .output()
1764 .unwrap();
1765
1766 Command::new("git")
1767 .args(["commit", "-m", "Add authentication feature"])
1768 .current_dir(&repo_path)
1769 .output()
1770 .unwrap();
1771
1772 let commit1_hash = Command::new("git")
1773 .args(["rev-parse", "HEAD"])
1774 .current_dir(&repo_path)
1775 .output()
1776 .unwrap();
1777 let commit1_hash = String::from_utf8_lossy(&commit1_hash.stdout)
1778 .trim()
1779 .to_string();
1780
1781 let entry1_id = manager
1783 .push_to_stack(
1784 "feature/auth".to_string(),
1785 commit1_hash,
1786 "Add authentication feature".to_string(),
1787 "main".to_string(),
1788 )
1789 .unwrap();
1790
1791 assert!(manager
1793 .get_active_stack()
1794 .unwrap()
1795 .get_entry(&entry1_id)
1796 .is_some());
1797
1798 std::fs::write(repo_path.join("file2.txt"), "content2").unwrap();
1800 Command::new("git")
1801 .args(["add", "file2.txt"])
1802 .current_dir(&repo_path)
1803 .output()
1804 .unwrap();
1805
1806 Command::new("git")
1807 .args(["commit", "-m", "Different commit message"])
1808 .current_dir(&repo_path)
1809 .output()
1810 .unwrap();
1811
1812 let commit2_hash = Command::new("git")
1813 .args(["rev-parse", "HEAD"])
1814 .current_dir(&repo_path)
1815 .output()
1816 .unwrap();
1817 let commit2_hash = String::from_utf8_lossy(&commit2_hash.stdout)
1818 .trim()
1819 .to_string();
1820
1821 let result = manager.push_to_stack(
1823 "feature/auth2".to_string(),
1824 commit2_hash.clone(),
1825 "Add authentication feature".to_string(), "main".to_string(),
1827 );
1828
1829 assert!(result.is_err());
1831 let error = result.unwrap_err();
1832 assert!(matches!(error, CascadeError::Validation(_)));
1833
1834 let error_msg = error.to_string();
1836 assert!(error_msg.contains("Duplicate commit message"));
1837 assert!(error_msg.contains("Add authentication feature"));
1838 assert!(error_msg.contains("š” Consider using a more specific message"));
1839
1840 let entry2_id = manager
1842 .push_to_stack(
1843 "feature/auth2".to_string(),
1844 commit2_hash,
1845 "Add authentication validation".to_string(), "main".to_string(),
1847 )
1848 .unwrap();
1849
1850 let stack = manager.get_active_stack().unwrap();
1852 assert_eq!(stack.entries.len(), 2);
1853 assert!(stack.get_entry(&entry1_id).is_some());
1854 assert!(stack.get_entry(&entry2_id).is_some());
1855 }
1856
1857 #[test]
1858 fn test_duplicate_message_with_different_case() {
1859 let (_temp_dir, repo_path) = create_test_repo();
1860 let mut manager = StackManager::new(&repo_path).unwrap();
1861
1862 manager
1863 .create_stack("test-stack".to_string(), None, None)
1864 .unwrap();
1865
1866 std::fs::write(repo_path.join("file1.txt"), "content1").unwrap();
1868 Command::new("git")
1869 .args(["add", "file1.txt"])
1870 .current_dir(&repo_path)
1871 .output()
1872 .unwrap();
1873
1874 Command::new("git")
1875 .args(["commit", "-m", "fix bug"])
1876 .current_dir(&repo_path)
1877 .output()
1878 .unwrap();
1879
1880 let commit1_hash = Command::new("git")
1881 .args(["rev-parse", "HEAD"])
1882 .current_dir(&repo_path)
1883 .output()
1884 .unwrap();
1885 let commit1_hash = String::from_utf8_lossy(&commit1_hash.stdout)
1886 .trim()
1887 .to_string();
1888
1889 manager
1890 .push_to_stack(
1891 "feature/fix1".to_string(),
1892 commit1_hash,
1893 "fix bug".to_string(),
1894 "main".to_string(),
1895 )
1896 .unwrap();
1897
1898 std::fs::write(repo_path.join("file2.txt"), "content2").unwrap();
1900 Command::new("git")
1901 .args(["add", "file2.txt"])
1902 .current_dir(&repo_path)
1903 .output()
1904 .unwrap();
1905
1906 Command::new("git")
1907 .args(["commit", "-m", "Fix Bug"])
1908 .current_dir(&repo_path)
1909 .output()
1910 .unwrap();
1911
1912 let commit2_hash = Command::new("git")
1913 .args(["rev-parse", "HEAD"])
1914 .current_dir(&repo_path)
1915 .output()
1916 .unwrap();
1917 let commit2_hash = String::from_utf8_lossy(&commit2_hash.stdout)
1918 .trim()
1919 .to_string();
1920
1921 let result = manager.push_to_stack(
1923 "feature/fix2".to_string(),
1924 commit2_hash,
1925 "Fix Bug".to_string(), "main".to_string(),
1927 );
1928
1929 assert!(result.is_ok());
1931 }
1932
1933 #[test]
1934 fn test_duplicate_message_across_different_stacks() {
1935 let (_temp_dir, repo_path) = create_test_repo();
1936 let mut manager = StackManager::new(&repo_path).unwrap();
1937
1938 let stack1_id = manager
1940 .create_stack("stack1".to_string(), None, None)
1941 .unwrap();
1942
1943 std::fs::write(repo_path.join("file1.txt"), "content1").unwrap();
1944 Command::new("git")
1945 .args(["add", "file1.txt"])
1946 .current_dir(&repo_path)
1947 .output()
1948 .unwrap();
1949
1950 Command::new("git")
1951 .args(["commit", "-m", "shared message"])
1952 .current_dir(&repo_path)
1953 .output()
1954 .unwrap();
1955
1956 let commit1_hash = Command::new("git")
1957 .args(["rev-parse", "HEAD"])
1958 .current_dir(&repo_path)
1959 .output()
1960 .unwrap();
1961 let commit1_hash = String::from_utf8_lossy(&commit1_hash.stdout)
1962 .trim()
1963 .to_string();
1964
1965 manager
1966 .push_to_stack(
1967 "feature/shared1".to_string(),
1968 commit1_hash,
1969 "shared message".to_string(),
1970 "main".to_string(),
1971 )
1972 .unwrap();
1973
1974 let stack2_id = manager
1976 .create_stack("stack2".to_string(), None, None)
1977 .unwrap();
1978
1979 manager.set_active_stack(Some(stack2_id)).unwrap();
1981
1982 std::fs::write(repo_path.join("file2.txt"), "content2").unwrap();
1984 Command::new("git")
1985 .args(["add", "file2.txt"])
1986 .current_dir(&repo_path)
1987 .output()
1988 .unwrap();
1989
1990 Command::new("git")
1991 .args(["commit", "-m", "shared message"])
1992 .current_dir(&repo_path)
1993 .output()
1994 .unwrap();
1995
1996 let commit2_hash = Command::new("git")
1997 .args(["rev-parse", "HEAD"])
1998 .current_dir(&repo_path)
1999 .output()
2000 .unwrap();
2001 let commit2_hash = String::from_utf8_lossy(&commit2_hash.stdout)
2002 .trim()
2003 .to_string();
2004
2005 let result = manager.push_to_stack(
2007 "feature/shared2".to_string(),
2008 commit2_hash,
2009 "shared message".to_string(), "main".to_string(),
2011 );
2012
2013 assert!(result.is_ok());
2015
2016 let stack1 = manager.get_stack(&stack1_id).unwrap();
2018 let stack2 = manager.get_stack(&stack2_id).unwrap();
2019
2020 assert_eq!(stack1.entries.len(), 1);
2021 assert_eq!(stack2.entries.len(), 1);
2022 assert_eq!(stack1.entries[0].message, "shared message");
2023 assert_eq!(stack2.entries[0].message, "shared message");
2024 }
2025
2026 #[test]
2027 fn test_duplicate_after_pop() {
2028 let (_temp_dir, repo_path) = create_test_repo();
2029 let mut manager = StackManager::new(&repo_path).unwrap();
2030
2031 manager
2032 .create_stack("test-stack".to_string(), None, None)
2033 .unwrap();
2034
2035 std::fs::write(repo_path.join("file1.txt"), "content1").unwrap();
2037 Command::new("git")
2038 .args(["add", "file1.txt"])
2039 .current_dir(&repo_path)
2040 .output()
2041 .unwrap();
2042
2043 Command::new("git")
2044 .args(["commit", "-m", "temporary message"])
2045 .current_dir(&repo_path)
2046 .output()
2047 .unwrap();
2048
2049 let commit1_hash = Command::new("git")
2050 .args(["rev-parse", "HEAD"])
2051 .current_dir(&repo_path)
2052 .output()
2053 .unwrap();
2054 let commit1_hash = String::from_utf8_lossy(&commit1_hash.stdout)
2055 .trim()
2056 .to_string();
2057
2058 manager
2059 .push_to_stack(
2060 "feature/temp".to_string(),
2061 commit1_hash,
2062 "temporary message".to_string(),
2063 "main".to_string(),
2064 )
2065 .unwrap();
2066
2067 let popped = manager.pop_from_stack().unwrap();
2069 assert_eq!(popped.message, "temporary message");
2070
2071 std::fs::write(repo_path.join("file2.txt"), "content2").unwrap();
2073 Command::new("git")
2074 .args(["add", "file2.txt"])
2075 .current_dir(&repo_path)
2076 .output()
2077 .unwrap();
2078
2079 Command::new("git")
2080 .args(["commit", "-m", "temporary message"])
2081 .current_dir(&repo_path)
2082 .output()
2083 .unwrap();
2084
2085 let commit2_hash = Command::new("git")
2086 .args(["rev-parse", "HEAD"])
2087 .current_dir(&repo_path)
2088 .output()
2089 .unwrap();
2090 let commit2_hash = String::from_utf8_lossy(&commit2_hash.stdout)
2091 .trim()
2092 .to_string();
2093
2094 let result = manager.push_to_stack(
2096 "feature/temp2".to_string(),
2097 commit2_hash,
2098 "temporary message".to_string(),
2099 "main".to_string(),
2100 );
2101
2102 assert!(result.is_ok());
2103 }
2104}